{"spec_id":"flowmap-origin-destination","library":"muix","language":"javascript","code":"// anyplot.ai\n// flowmap-origin-destination: Origin-Destination Flow Map\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\nimport { useState } from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { ChartsGrid } from \"@mui/x-charts/ChartsGrid\";\nimport { useXScale, useYScale, useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst TITLE = \"flowmap-origin-destination · javascript · muix · anyplot.ai\";\n\n// --- Data: major container ports (in-memory, deterministic) -----------------\nconst ports = {\n  SHA: { name: \"Shanghai\", lat: 31.23, lon: 121.47 },\n  SIN: { name: \"Singapore\", lat: 1.29, lon: 103.85 },\n  BUS: { name: \"Busan\", lat: 35.18, lon: 129.08 },\n  HKG: { name: \"Hong Kong\", lat: 22.32, lon: 114.17 },\n  RTM: { name: \"Rotterdam\", lat: 51.92, lon: 4.48 },\n  LAX: { name: \"Los Angeles\", lat: 33.73, lon: -118.26 },\n  JEA: { name: \"Jebel Ali\", lat: 25.01, lon: 55.06 },\n  HAM: { name: \"Hamburg\", lat: 53.55, lon: 9.99 },\n  NYC: { name: \"New York\", lat: 40.67, lon: -74.13 },\n  SSZ: { name: \"Santos\", lat: -23.96, lon: -46.33 },\n  NSA: { name: \"Mumbai\", lat: 18.95, lon: 72.95 },\n  CMB: { name: \"Colombo\", lat: 6.95, lon: 79.84 },\n  PIR: { name: \"Piraeus\", lat: 37.94, lon: 23.65 },\n  VAN: { name: \"Vancouver\", lat: 49.29, lon: -123.11 },\n};\n\n// Container shipping volumes: thousand TEU/year between port pairs\nconst flows = [\n  { from: \"SHA\", to: \"LAX\", volume: 1450 },\n  { from: \"SHA\", to: \"RTM\", volume: 980 },\n  { from: \"SHA\", to: \"SIN\", volume: 620 },\n  { from: \"SIN\", to: \"RTM\", volume: 710 },\n  { from: \"SIN\", to: \"JEA\", volume: 540 },\n  { from: \"SIN\", to: \"NSA\", volume: 460 },\n  { from: \"HKG\", to: \"LAX\", volume: 890 },\n  { from: \"HKG\", to: \"RTM\", volume: 520 },\n  { from: \"BUS\", to: \"LAX\", volume: 780 },\n  { from: \"BUS\", to: \"RTM\", volume: 410 },\n  { from: \"RTM\", to: \"NYC\", volume: 630 },\n  { from: \"JEA\", to: \"RTM\", volume: 590 },\n  { from: \"JEA\", to: \"NSA\", volume: 350 },\n  { from: \"NSA\", to: \"RTM\", volume: 480 },\n  { from: \"NSA\", to: \"CMB\", volume: 260 },\n  { from: \"CMB\", to: \"SIN\", volume: 300 },\n  { from: \"HAM\", to: \"NYC\", volume: 410 },\n  { from: \"PIR\", to: \"SIN\", volume: 330 },\n  { from: \"SSZ\", to: \"RTM\", volume: 360 },\n  { from: \"VAN\", to: \"SHA\", volume: 520 },\n  { from: \"VAN\", to: \"SIN\", volume: 300 },\n];\n\nconst volumes = flows.map((f) => f.volume);\nconst MIN_VOLUME = Math.min(...volumes);\nconst MAX_VOLUME = Math.max(...volumes);\n\n// Hub throughput = total volume touching a port (both directions)\nconst throughputByPort = {};\nflows.forEach((f) => {\n  throughputByPort[f.from] = (throughputByPort[f.from] || 0) + f.volume;\n  throughputByPort[f.to] = (throughputByPort[f.to] || 0) + f.volume;\n});\nconst throughputValues = Object.values(throughputByPort);\nconst MIN_THROUGHPUT = Math.min(...throughputValues);\nconst MAX_THROUGHPUT = Math.max(...throughputValues);\n\n// Top hub ports get bolder, larger labels so the busiest nodes read as a\n// clear typographic tier above the rest, sharpening the hierarchy.\nconst HUB_COUNT = 4;\nconst hubCodes = new Set(\n  Object.entries(throughputByPort)\n    .sort((a, b) => b[1] - a[1])\n    .slice(0, HUB_COUNT)\n    .map(([code]) => code),\n);\n\n// Flows are drawn largest-last so the dominant corridors stay on top of the\n// pile-up around hub ports, and rendered with per-arc bow sign/magnitude so\n// arcs sharing a hub fan out instead of stacking on one identical curve.\nconst sortedFlows = [...flows].sort((a, b) => a.volume - b.volume);\n\n// Manual label nudges so the closely clustered European ports don't collide\n// — markers stay at their true coordinates, only the text shifts.\nconst labelNudge = {\n  RTM: { dx: -16, dy: -10 },\n  HAM: { dx: 12, dy: -18 },\n  PIR: { dx: 10, dy: 20 },\n};\n\nconst lons = Object.values(ports).map((p) => p.lon);\nconst lats = Object.values(ports).map((p) => p.lat);\nconst LON_MIN = Math.min(...lons) - 16;\nconst LON_MAX = Math.max(...lons) + 16;\nconst LAT_MIN = Math.min(...lats) - 10;\nconst LAT_MAX = Math.max(...lats) + 10;\n\n// Interpolate along the Imprint sequential ramp (brand green -> blue) to\n// encode flow magnitude in the arc color, per default-style-guide.md\n// \"Continuous Data\" (imprint_seq is single-polarity, so it fits volume).\nfunction lerpSeq(ratio) {\n  const a = parseInt(t.seq[0].slice(1), 16);\n  const b = parseInt(t.seq[1].slice(1), 16);\n  const channel = (shift) => {\n    const va = (a >> shift) & 255;\n    const vb = (b >> shift) & 255;\n    return Math.round(va + (vb - va) * ratio);\n  };\n  return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;\n}\n\n// Simplified continent silhouettes (low-poly, hand-traced) so the chart reads\n// as a map rather than a bare lon/lat grid. Rendered through the same\n// xScale/yScale hooks as the flow arcs and clipped to the drawing area.\nconst CONTINENTS = [\n  [\n    [-165, 68], [-135, 58], [-125, 48], [-118, 33], [-105, 20], [-95, 15], [-80, 25],\n    [-75, 35], [-65, 45], [-70, 50], [-95, 62], [-130, 70], [-165, 68],\n  ], // North America\n  [\n    [-80, 10], [-70, -5], [-70, -20], [-68, -35], [-70, -50], [-65, -45], [-50, -25],\n    [-35, -8], [-50, 2], [-65, 8], [-80, 10],\n  ], // South America\n  [\n    [-10, 43], [0, 36], [15, 38], [25, 40], [30, 45], [40, 55], [25, 60],\n    [5, 50], [-5, 52], [-10, 43],\n  ], // Europe\n  [\n    [-17, 20], [-10, 5], [5, 5], [10, -5], [15, -30], [25, -33], [35, -15],\n    [42, 10], [35, 32], [10, 37], [-17, 20],\n  ], // Africa\n  [\n    [30, 45], [40, 15], [55, 25], [70, 20], [80, 10], [95, 15], [105, 10],\n    [115, 22], [130, 35], [140, 40], [135, 50], [100, 55], [70, 55], [45, 45], [30, 45],\n  ], // Asia\n];\n\n// --- Overlay: title drawn in the reserved top margin -------------------------\n// Plain SVG <text>, not ChartsText: ChartsText applies its own measured-width\n// anchor offset on top of the native SVG text-anchor, which for a long\n// middle-anchored string like this double-shifts it off-center.\nfunction MapTitle() {\n  const { width } = window.ANYPLOT_SIZE;\n  return (\n    <text x={width / 2} y={40} textAnchor=\"middle\" dominantBaseline=\"hanging\" fontSize={22} fontWeight={500} fill={t.ink}>\n      {TITLE}\n    </text>\n  );\n}\n\n// --- Overlay: simplified continent silhouettes behind the flow arcs ---------\nfunction WorldOutline() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const drawingArea = useDrawingArea();\n  return (\n    <g>\n      <defs>\n        <clipPath id=\"worldClip\">\n          <rect x={drawingArea.left} y={drawingArea.top} width={drawingArea.width} height={drawingArea.height} />\n        </clipPath>\n      </defs>\n      <g clipPath=\"url(#worldClip)\">\n        {CONTINENTS.map((points, i) => {\n          const d = points.map(([lon, lat], j) => `${j === 0 ? \"M\" : \"L\"} ${xScale(lon)},${yScale(lat)}`).join(\" \") + \" Z\";\n          return <path key={i} d={d} fill={t.inkSoft} fillOpacity={0.12} stroke={t.inkSoft} strokeWidth={1} strokeOpacity={0.35} />;\n        })}\n      </g>\n    </g>\n  );\n}\n\n// --- Overlay: origin-destination flow arcs, direction arrows, port nodes ----\nfunction FlowOverlay({ onHoverChange }) {\n  const xScale = useXScale();\n  const yScale = useYScale();\n\n  return (\n    <g>\n      {sortedFlows.map((flow, i) => {\n        const origin = ports[flow.from];\n        const dest = ports[flow.to];\n        const x1 = xScale(origin.lon);\n        const y1 = yScale(origin.lat);\n        const x2 = xScale(dest.lon);\n        const y2 = yScale(dest.lat);\n\n        const dx = x2 - x1;\n        const dy = y2 - y1;\n        const dist = Math.sqrt(dx * dx + dy * dy) || 1;\n        const midX = (x1 + x2) / 2;\n        const midY = (y1 + y2) / 2;\n        // Perpendicular bow approximates a great-circle arc on the flat projection.\n        // Sign/magnitude vary per arc so flows sharing a hub fan out instead of\n        // stacking on one identical curve through the hub circle.\n        const ux = -dy / dist;\n        const uy = dx / dist;\n        const bowSign = i % 2 === 0 ? 1 : -1;\n        const bowMagnitude = 0.09 + ((i * 53) % 7) * 0.015;\n        const bow = dist * bowMagnitude * bowSign;\n        const controlX = midX + ux * bow;\n        const controlY = midY + uy * bow;\n\n        const ratio = (flow.volume - MIN_VOLUME) / (MAX_VOLUME - MIN_VOLUME || 1);\n        const strokeWidth = 2 + ratio * 7;\n        const strokeOpacity = 0.35 + ratio * 0.35;\n        const color = lerpSeq(ratio);\n\n        // A quadratic Bezier's tangent at t=0.5 equals the chord direction\n        // (P2 - P0), so the arrowhead angle is simply atan2(dy, dx); the\n        // on-curve midpoint itself is the weighted blend below.\n        const arrowX = 0.25 * x1 + 0.5 * controlX + 0.25 * x2;\n        const arrowY = 0.25 * y1 + 0.5 * controlY + 0.25 * y2;\n        const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;\n\n        const flowTooltip = {\n          label: `${origin.name} → ${dest.name}`,\n          detail: `${flow.volume.toLocaleString()} thousand TEU/year`,\n          x: arrowX,\n          y: arrowY,\n        };\n\n        return (\n          <g key={`${flow.from}-${flow.to}-${i}`}>\n            <path\n              d={`M ${x1},${y1} Q ${controlX},${controlY} ${x2},${y2}`}\n              fill=\"none\"\n              stroke={color}\n              strokeWidth={strokeWidth}\n              strokeOpacity={strokeOpacity}\n              strokeLinecap=\"round\"\n            />\n            {/* Wider transparent hit path: the visible stroke is often too thin to hover reliably. */}\n            <path\n              d={`M ${x1},${y1} Q ${controlX},${controlY} ${x2},${y2}`}\n              fill=\"none\"\n              stroke=\"transparent\"\n              strokeWidth={Math.max(strokeWidth, 16)}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(flowTooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n            <polygon\n              points=\"-7,-4.5 7,0 -7,4.5\"\n              transform={`translate(${arrowX}, ${arrowY}) rotate(${angleDeg})`}\n              fill={color}\n              fillOpacity={Math.min(strokeOpacity + 0.15, 0.9)}\n            />\n          </g>\n        );\n      })}\n      {Object.entries(ports).map(([code, port]) => {\n        const cx = xScale(port.lon);\n        const cy = yScale(port.lat);\n        const throughput = throughputByPort[code] || 0;\n        const throughputRatio = (throughput - MIN_THROUGHPUT) / (MAX_THROUGHPUT - MIN_THROUGHPUT || 1);\n        const radius = 8 + throughputRatio * 20;\n        const nudge = labelNudge[code] || { dx: 0, dy: -(radius + 10) };\n        const isHub = hubCodes.has(code);\n        const portTooltip = {\n          label: port.name,\n          detail: `Throughput: ${throughput.toLocaleString()} thousand TEU/year`,\n          x: cx,\n          y: cy - radius - 8,\n        };\n        return (\n          <g key={code}>\n            <circle\n              cx={cx}\n              cy={cy}\n              r={radius}\n              fill={t.palette[0]}\n              stroke={t.pageBg}\n              strokeWidth={2.5}\n              style={{ cursor: \"pointer\" }}\n              onMouseEnter={() => onHoverChange(portTooltip)}\n              onMouseLeave={() => onHoverChange(null)}\n            />\n            <text\n              x={cx + nudge.dx}\n              y={cy + nudge.dy}\n              textAnchor=\"middle\"\n              fontSize={isHub ? 16 : 14}\n              fontWeight={isHub ? 700 : 500}\n              fill={t.ink}\n              style={{ pointerEvents: \"none\" }}\n            >\n              {code}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Overlay: hover tooltip for flow arcs and port circles ------------------\nfunction HoverTooltip({ hover }) {\n  if (!hover) return null;\n  const charWidth = 7.2;\n  const width = Math.max(hover.label.length, hover.detail.length) * charWidth + 20;\n  const height = 44;\n  const x = hover.x - width / 2;\n  const y = hover.y - height - 12;\n  return (\n    <g style={{ pointerEvents: \"none\" }}>\n      <rect x={x} y={y} width={width} height={height} rx={6} fill={t.elevatedBg} stroke={t.inkSoft} strokeOpacity={0.4} />\n      <text x={hover.x} y={y + 18} textAnchor=\"middle\" fontSize={13} fontWeight={600} fill={t.ink}>\n        {hover.label}\n      </text>\n      <text x={hover.x} y={y + 34} textAnchor=\"middle\" fontSize={12} fill={t.inkSoft}>\n        {hover.detail}\n      </text>\n    </g>\n  );\n}\n\n// --- Overlay: color-ramp legend for flow volume, plus a usage caption -------\nfunction Legend() {\n  const drawingArea = useDrawingArea();\n  const barX = drawingArea.left;\n  const barY = drawingArea.top + drawingArea.height + 70;\n  const barWidth = 260;\n  const barHeight = 14;\n\n  return (\n    <g>\n      <defs>\n        <linearGradient id=\"flowLegendGradient\" x1=\"0%\" x2=\"100%\" y1=\"0%\" y2=\"0%\">\n          <stop offset=\"0%\" stopColor={t.seq[0]} />\n          <stop offset=\"100%\" stopColor={t.seq[1]} />\n        </linearGradient>\n      </defs>\n      <text x={barX} y={barY - 12} textAnchor=\"start\" fontSize={14} fontWeight={400} fill={t.inkSoft}>\n        Flow volume (thousand TEU/year)\n      </text>\n      <rect x={barX} y={barY} width={barWidth} height={barHeight} fill=\"url(#flowLegendGradient)\" rx={3} />\n      <text x={barX} y={barY + barHeight + 20} textAnchor=\"start\" fontSize={13} fill={t.inkSoft}>\n        {MIN_VOLUME}\n      </text>\n      <text x={barX + barWidth} y={barY + barHeight + 20} textAnchor=\"end\" fontSize={13} fill={t.inkSoft}>\n        {MAX_VOLUME}\n      </text>\n      <text\n        x={drawingArea.left + drawingArea.width}\n        y={barY - 12}\n        textAnchor=\"end\"\n        fontSize={14}\n        fill={t.inkSoft}\n      >\n        Circle size = port throughput · arrows show flow direction\n      </text>\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  const [hover, setHover] = useState(null);\n  return (\n    <ChartContainer\n      width={window.ANYPLOT_SIZE.width}\n      height={window.ANYPLOT_SIZE.height}\n      margin={{ top: 90, right: 60, bottom: 150, left: 70 }}\n      series={[]}\n      skipAnimation\n      disableAxisListener\n      xAxis={[\n        {\n          scaleType: \"linear\",\n          min: LON_MIN,\n          max: LON_MAX,\n          label: \"Longitude (°)\",\n          valueFormatter: (v) => `${v}°`,\n          labelStyle: { fontSize: 16, fill: t.ink },\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n        },\n      ]}\n      yAxis={[\n        {\n          scaleType: \"linear\",\n          min: LAT_MIN,\n          max: LAT_MAX,\n          label: \"Latitude (°)\",\n          valueFormatter: (v) => `${v}°`,\n          labelStyle: { fontSize: 16, fill: t.ink },\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n        },\n      ]}\n    >\n      {/* Horizontal-only reference lines: the continent silhouettes already carry\n          geographic context, so a vertical grid would just compete with the arcs. */}\n      <ChartsGrid horizontal />\n      <WorldOutline />\n      <FlowOverlay onHoverChange={setHover} />\n      <ChartsXAxis />\n      <ChartsYAxis />\n      <MapTitle />\n      <Legend />\n      <HoverTooltip hover={hover} />\n    </ChartContainer>\n  );\n}\n"}