{"spec_id":"flowmap-origin-destination","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// flowmap-origin-destination: Origin-Destination Flow Map\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\n\n// --- Basemap chrome (not data — Imprint palette only governs data colors) --\nconst LAND = THEME === \"light\" ? \"#E4E0D2\" : \"#33332C\";\nconst LAND_EDGE = THEME === \"light\" ? \"#A79F8A\" : \"#4A4A40\";\nconst OCEAN = THEME === \"light\" ? \"#CFE3EF\" : \"#16222E\";\n\n// --- Data: container-shipping volumes between major world ports, thousand --\n// --- TEU/year (twenty-foot equivalent units) — synthetic but realistic -----\nconst PORTS = {\n  Shanghai: { lat: 31.23, lon: 121.47 },\n  Singapore: { lat: 1.35, lon: 103.82 },\n  Rotterdam: { lat: 51.92, lon: 4.48 },\n  \"Los Angeles\": { lat: 33.73, lon: -118.26 },\n  \"Jebel Ali\": { lat: 25.01, lon: 55.06 },\n  Busan: { lat: 35.18, lon: 129.08 },\n  Hamburg: { lat: 53.55, lon: 9.99 },\n  Santos: { lat: -23.96, lon: -46.33 },\n  \"Nhava Sheva\": { lat: 18.95, lon: 72.95 },\n  Sydney: { lat: -33.87, lon: 151.21 },\n  \"New York\": { lat: 40.67, lon: -74.14 },\n};\n\nconst FLOWS = [\n  { origin: \"Shanghai\", dest: \"Los Angeles\", teu: 185 },\n  { origin: \"Shanghai\", dest: \"Rotterdam\", teu: 142 },\n  { origin: \"Shanghai\", dest: \"Hamburg\", teu: 98 },\n  { origin: \"Shanghai\", dest: \"Singapore\", teu: 210 },\n  { origin: \"Shanghai\", dest: \"New York\", teu: 118 },\n  { origin: \"Shanghai\", dest: \"Jebel Ali\", teu: 89 },\n  { origin: \"Singapore\", dest: \"Rotterdam\", teu: 156 },\n  { origin: \"Singapore\", dest: \"Jebel Ali\", teu: 133 },\n  { origin: \"Singapore\", dest: \"Sydney\", teu: 88 },\n  { origin: \"Busan\", dest: \"Los Angeles\", teu: 120 },\n  { origin: \"Busan\", dest: \"Shanghai\", teu: 95 },\n  { origin: \"Busan\", dest: \"Rotterdam\", teu: 68 },\n  { origin: \"Jebel Ali\", dest: \"Rotterdam\", teu: 101 },\n  { origin: \"Jebel Ali\", dest: \"Hamburg\", teu: 76 },\n  { origin: \"Nhava Sheva\", dest: \"Jebel Ali\", teu: 84 },\n  { origin: \"Nhava Sheva\", dest: \"Rotterdam\", teu: 63 },\n  { origin: \"Santos\", dest: \"Rotterdam\", teu: 71 },\n  { origin: \"Santos\", dest: \"Hamburg\", teu: 58 },\n  { origin: \"Santos\", dest: \"Shanghai\", teu: 66 },\n  { origin: \"New York\", dest: \"Rotterdam\", teu: 112 },\n  { origin: \"New York\", dest: \"Hamburg\", teu: 79 },\n  { origin: \"Sydney\", dest: \"Shanghai\", teu: 74 },\n];\n\nconst teuValues = FLOWS.map((flow) => flow.teu);\nconst teuMin = Math.min(...teuValues);\nconst teuMax = Math.max(...teuValues);\n\n// --- Simplified world coastlines (equirectangular lon/lat vertices), used --\n// --- only as geographic context — not the data being visualized -----------\nconst CONTINENTS = [\n  [\n    [-165, 62],\n    [-150, 60],\n    [-130, 55],\n    [-124, 40],\n    [-117, 32],\n    [-105, 22],\n    [-90, 16],\n    [-97, 26],\n    [-82, 24],\n    [-80, 30],\n    [-70, 42],\n    [-60, 46],\n    [-75, 50],\n    [-95, 52],\n    [-125, 55],\n    [-150, 58],\n    [-165, 62],\n  ],\n  [\n    [-79, 8],\n    [-70, -2],\n    [-55, -3],\n    [-45, -3],\n    [-35, -10],\n    [-35, -20],\n    [-48, -26],\n    [-58, -35],\n    [-68, -54],\n    [-72, -45],\n    [-70, -25],\n    [-78, -5],\n    [-79, 8],\n  ],\n  [\n    [-9, 43],\n    [3, 43],\n    [10, 45],\n    [13, 41],\n    [20, 40],\n    [27, 39],\n    [35, 42],\n    [41, 47],\n    [30, 50],\n    [19, 54],\n    [9, 54],\n    [1, 51],\n    [-6, 48],\n    [-9, 43],\n  ],\n  [\n    [-17, 15],\n    [-9, 32],\n    [-1, 36],\n    [10, 37],\n    [20, 32],\n    [33, 30],\n    [43, 12],\n    [51, 12],\n    [42, -4],\n    [35, -22],\n    [27, -33],\n    [14, -23],\n    [9, 4],\n    [-5, 5],\n    [-17, 15],\n  ],\n  [\n    [27, 68],\n    [45, 68],\n    [60, 68],\n    [80, 73],\n    [105, 76],\n    [140, 72],\n    [165, 60],\n    [150, 46],\n    [135, 35],\n    [122, 32],\n    [104, 10],\n    [96, 6],\n    [88, 22],\n    [76, 10],\n    [70, 25],\n    [50, 30],\n    [43, 12],\n    [33, 30],\n    [27, 68],\n  ],\n  [\n    [130, 32],\n    [135, 35],\n    [140, 36],\n    [141, 45],\n    [144, 38],\n    [135, 35],\n    [130, 32],\n  ],\n  [\n    [113, -22],\n    [130, -12],\n    [141, -13],\n    [150, -23],\n    [150, -38],\n    [130, -32],\n    [113, -25],\n    [113, -22],\n  ],\n];\n\nfunction smoothPolygon(points, iterations) {\n  let pts = points;\n  for (let iter = 0; iter < iterations; iter++) {\n    const refined = [];\n    for (let i = 0; i < pts.length - 1; i++) {\n      const [x0, y0] = pts[i];\n      const [x1, y1] = pts[i + 1];\n      refined.push([x0 + 0.25 * (x1 - x0), y0 + 0.25 * (y1 - y0)]);\n      refined.push([x0 + 0.75 * (x1 - x0), y0 + 0.75 * (y1 - y0)]);\n    }\n    refined.push(refined[0]);\n    pts = refined;\n  }\n  return pts;\n}\n\nfunction drawContinents(chart) {\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n  CONTINENTS.forEach((polygon) => {\n    const smoothed = smoothPolygon(polygon, 2);\n    const path = smoothed.map((point, i) => [\n      i === 0 ? \"M\" : \"L\",\n      xAxis.toPixels(point[0], false),\n      yAxis.toPixels(point[1], false),\n    ]);\n    path.push([\"Z\"]);\n    chart.renderer\n      .path(path)\n      .attr({ fill: LAND, stroke: LAND_EDGE, \"stroke-width\": 1, zIndex: 1 })\n      .add();\n  });\n}\n\n// --- Great-arc approximation: quadratic Bezier bowed toward the north, ------\n// --- sampled into a polyline so it renders as a native, hoverable series ---\nconst CURVATURE_MIN = 0.1;\nconst CURVATURE_MAX = 0.26;\n\nfunction hashStr(str) {\n  let h = 0;\n  for (let i = 0; i < str.length; i++) {\n    h = (h * 31 + str.charCodeAt(i)) >>> 0;\n  }\n  return h;\n}\n\n// Deterministic per-pair jitter keeps the curvature within a fixed band so\n// every arc still bows toward the same rotational sense, but arcs sharing a\n// hub endpoint fan apart instead of stacking on top of one another.\nfunction curvatureFor(origin, dest) {\n  const frac = (hashStr(`${origin}->${dest}`) % 1000) / 1000;\n  return CURVATURE_MIN + frac * (CURVATURE_MAX - CURVATURE_MIN);\n}\n\nfunction arcPoints(lon1, lat1, lon2, lat2, steps, curvature) {\n  const mx = (lon1 + lon2) / 2;\n  const my = (lat1 + lat2) / 2;\n  const dx = lon2 - lon1;\n  const dy = lat2 - lat1;\n  const dist = Math.sqrt(dx * dx + dy * dy) || 1;\n  // Perpendicular offset (always the same rotational sense) bows every arc\n  // consistently, which is what makes a flow map read as a coherent set.\n  const nx = -dy / dist;\n  const ny = dx / dist;\n  const cx = mx + nx * dist * curvature;\n  const cy = my + ny * dist * curvature;\n  const points = [];\n  for (let i = 0; i <= steps; i++) {\n    const tt = i / steps;\n    const u = 1 - tt;\n    points.push({\n      x: u * u * lon1 + 2 * u * tt * cx + tt * tt * lon2,\n      y: u * u * lat1 + 2 * u * tt * cy + tt * tt * lat2,\n    });\n  }\n  return points;\n}\n\nfunction lerpColor(hexA, hexB, frac) {\n  const a = [1, 3, 5].map((i) => parseInt(hexA.slice(i, i + 2), 16));\n  const b = [1, 3, 5].map((i) => parseInt(hexB.slice(i, i + 2), 16));\n  const [r, g, bl] = a.map((c, i) => Math.round(c + (b[i] - c) * frac));\n  return `${r}, ${g}, ${bl}`;\n}\n\nconst MIN_WIDTH = 1.5;\nconst MAX_WIDTH = 9;\n\nconst flowSeries = FLOWS.map((flow) => {\n  const origin = PORTS[flow.origin];\n  const dest = PORTS[flow.dest];\n  const norm = (flow.teu - teuMin) / (teuMax - teuMin);\n  const rgb = lerpColor(t.seq[0], t.seq[1], norm);\n  const curvature = curvatureFor(flow.origin, flow.dest);\n  return {\n    type: \"line\",\n    name: `${flow.origin} → ${flow.dest}`,\n    data: arcPoints(origin.lon, origin.lat, dest.lon, dest.lat, 28, curvature),\n    color: `rgba(${rgb}, 0.6)`,\n    lineWidth: MIN_WIDTH + (MAX_WIDTH - MIN_WIDTH) * norm,\n    marker: { enabled: false },\n    enableMouseTracking: true,\n    showInLegend: false,\n    zIndex: 3,\n    flowMeta: { origin: flow.origin, dest: flow.dest, teu: flow.teu },\n  };\n});\n\n// --- Port markers: bubble radius scaled by total handled volume (origin + --\n// --- destination combined) — reveals which ports are the major hubs -------\nconst portTotals = {};\nObject.keys(PORTS).forEach((name) => {\n  portTotals[name] = FLOWS.filter(\n    (flow) => flow.origin === name || flow.dest === name,\n  ).reduce((sum, flow) => sum + flow.teu, 0);\n});\nconst totalMin = Math.min(...Object.values(portTotals));\nconst totalMax = Math.max(...Object.values(portTotals));\nconst MIN_R = 8;\nconst MAX_R = 26;\n\n// Direct-label the busiest hubs so the major corridors read without hovering.\nconst HUB_NAMES = Object.entries(portTotals)\n  .sort((a, b) => b[1] - a[1])\n  .slice(0, 4)\n  .map(([name]) => name);\n\nconst portSeries = {\n  type: \"scatter\",\n  name: \"Port\",\n  data: Object.entries(PORTS).map(([name, coord]) => {\n    const norm = (portTotals[name] - totalMin) / (totalMax - totalMin);\n    const radius = MIN_R + (MAX_R - MIN_R) * Math.sqrt(norm);\n    return {\n      x: coord.lon,\n      y: coord.lat,\n      name,\n      total: portTotals[name],\n      marker: {\n        symbol: \"circle\",\n        radius,\n        fillColor: t.palette[0],\n        lineColor: t.pageBg,\n        lineWidth: 2,\n      },\n      dataLabels: HUB_NAMES.includes(name)\n        ? {\n            enabled: true,\n            format: name,\n            allowOverlap: true,\n            y: -(radius + 10),\n            style: {\n              color: t.ink,\n              fontSize: \"13px\",\n              fontWeight: \"600\",\n              textOutline: `2px ${t.pageBg}`,\n            },\n          }\n        : undefined,\n    };\n  }),\n  color: t.palette[0],\n  showInLegend: false,\n  zIndex: 5,\n};\n\n// --- Manual legend: core Highcharts has no gradient/width legend widget ----\nfunction drawLegend(chart) {\n  const boxW = 300;\n  const boxH = 150;\n  const boxX = chart.plotLeft + 24;\n  const boxY = chart.plotTop + chart.plotHeight - boxH - 20;\n\n  chart.renderer\n    .rect(boxX, boxY, boxW, boxH, 6)\n    .attr({\n      fill: t.elevatedBg,\n      stroke: t.inkSoft,\n      \"stroke-width\": 1,\n      zIndex: 6,\n      opacity: 0.94,\n    })\n    .add();\n\n  chart.renderer\n    .text(\"Shipment volume (thousand TEU)\", boxX + 16, boxY + 24)\n    .attr({ zIndex: 7 })\n    .css({ color: t.ink, fontSize: \"15px\", fontWeight: \"600\" })\n    .add();\n\n  const sampleTeu = [teuMin, Math.round((teuMin + teuMax) / 2), teuMax];\n  let cursorY = boxY + 48;\n  sampleTeu.forEach((teu) => {\n    const norm = (teu - teuMin) / (teuMax - teuMin);\n    const rgb = lerpColor(t.seq[0], t.seq[1], norm);\n    const width = MIN_WIDTH + (MAX_WIDTH - MIN_WIDTH) * norm;\n    chart.renderer\n      .path([\n        [\"M\", boxX + 16, cursorY],\n        [\"L\", boxX + 76, cursorY],\n      ])\n      .attr({\n        stroke: `rgb(${rgb})`,\n        \"stroke-width\": width,\n        \"stroke-linecap\": \"round\",\n        zIndex: 7,\n      })\n      .add();\n    chart.renderer\n      .text(`~${teu} kTEU`, boxX + 92, cursorY + 5)\n      .attr({ zIndex: 7 })\n      .css({ color: t.inkSoft, fontSize: \"14px\" })\n      .add();\n    cursorY += 28;\n  });\n\n  chart.renderer\n    .text(\"Bubble size = port's total volume\", boxX + 16, boxY + boxH - 14)\n    .attr({ zIndex: 7 })\n    .css({ color: t.inkSoft, fontSize: \"13px\" })\n    .add();\n}\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"line\",\n    backgroundColor: \"transparent\",\n    plotBackgroundColor: OCEAN,\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load: function () {\n        drawContinents(this);\n        drawLegend(this);\n      },\n    },\n  },\n  credits: { enabled: false },\n  title: {\n    text: \"flowmap-origin-destination · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Container-shipping volumes between major world ports — arc width and color encode thousand-TEU flow, bubble size encodes port throughput\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    min: -180,\n    max: 180,\n    tickInterval: 30,\n    title: {\n      text: \"Longitude (°)\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    lineWidth: 0,\n    tickColor: t.inkSoft,\n    gridLineWidth: 1,\n    gridLineColor: t.grid,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  yAxis: {\n    min: -55,\n    max: 75,\n    tickInterval: 30,\n    title: {\n      text: \"Latitude (°)\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    lineWidth: 0,\n    tickColor: t.inkSoft,\n    gridLineWidth: 1,\n    gridLineColor: t.grid,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  legend: { enabled: false },\n  tooltip: {\n    headerFormat: \"\",\n    formatter: function () {\n      const meta = this.series.options.flowMeta;\n      if (meta) {\n        return `<b>${meta.origin} → ${meta.dest}</b><br/>${meta.teu.toLocaleString()} thousand TEU/year`;\n      }\n      return `<b>${this.point.name}</b><br/>Total volume: ${this.point.total.toLocaleString()} thousand TEU/year`;\n    },\n  },\n  plotOptions: {\n    series: {\n      animation: false,\n      states: { hover: { enabled: false } },\n    },\n  },\n  series: [...flowSeries, portSeries],\n});\n"}