Origin-Destination Flow Map — Plotly

An origin-destination flow map visualizes movement or transfer between geographic locations using curved arcs overlaid on a map. Each arc connects an origin point to a destination point, with line thickness proportional to the flow magnitude. This visualization excels at revealing spatial patterns in migration, trade, or travel data, making it easy to identify major corridors, hub locations, and directional imbalances in movement between places.

Origin-Destination Flow Map rendered with Plotly

Python source (Plotly)

""" anyplot.ai
flowmap-origin-destination: Origin-Destination Flow Map
Library: plotly 6.7.0 | Python 3.13.13
Quality: 84/100 | Updated: 2026-05-20
"""

import math
import os

import numpy as np
import plotly.graph_objects as go


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Basemap colors (theme-adaptive)
if THEME == "light":
    LAND_COLOR = "rgb(235, 232, 220)"
    OCEAN_COLOR = "rgb(215, 230, 245)"
    COAST_COLOR = "rgb(180, 175, 165)"
    COUNTRY_COLOR = "rgb(200, 195, 185)"
else:
    LAND_COLOR = "rgb(55, 55, 50)"
    OCEAN_COLOR = "rgb(28, 42, 60)"
    COAST_COLOR = "rgb(90, 88, 80)"
    COUNTRY_COLOR = "rgb(75, 73, 65)"

# Okabe-Ito blue (position 3) for arcs; green (position 1) for city nodes
ARC_RGB = "0, 114, 178"
CITY_COLOR = "#009E73"

# Data: Major international trade flows between world ports
np.random.seed(42)

cities = {
    "Shanghai": (31.2, 121.5),
    "Los Angeles": (34.1, -118.2),
    "Rotterdam": (51.9, 4.5),
    "Singapore": (1.3, 103.8),
    "Dubai": (25.3, 55.3),
    "New York": (40.7, -74.0),
    "Tokyo": (35.7, 139.7),
    "Hamburg": (53.6, 10.0),
    "Busan": (35.2, 129.1),
    "Hong Kong": (22.3, 114.2),
    "Santos": (-23.9, -46.3),
    "Mumbai": (19.1, 72.9),
    "Sydney": (-33.9, 151.2),
    "Cape Town": (-33.9, 18.4),
    "Panama City": (9.0, -79.5),
}

# Per-city label positions — East Asian cluster spread aggressively to avoid overlap
label_positions = {
    "Shanghai": "top left",
    "Tokyo": "top right",
    "Busan": "top center",
    "Hong Kong": "bottom left",
    "Singapore": "bottom right",
    "Rotterdam": "top left",
    "Hamburg": "top right",
    "New York": "top left",
    "Los Angeles": "bottom left",
    "Dubai": "bottom right",
    "Mumbai": "bottom left",
    "Santos": "bottom left",
    "Cape Town": "bottom left",
    "Sydney": "bottom right",
    "Panama City": "top left",
}

flows_data = [
    ("Shanghai", "Los Angeles", 85),
    ("Shanghai", "Rotterdam", 72),
    ("Singapore", "Rotterdam", 68),
    ("Shanghai", "New York", 58),
    ("Hong Kong", "Los Angeles", 52),
    ("Tokyo", "Shanghai", 48),
    ("Rotterdam", "New York", 45),
    ("Dubai", "Rotterdam", 42),
    ("Busan", "Los Angeles", 40),
    ("Shanghai", "Hamburg", 38),
    ("Mumbai", "Singapore", 35),
    ("Shanghai", "Sydney", 32),
    ("Hong Kong", "Dubai", 30),
    ("Santos", "Rotterdam", 28),
    ("Cape Town", "Singapore", 25),
    ("Shanghai", "Dubai", 45),
    ("Singapore", "Hong Kong", 55),
    ("Los Angeles", "Panama City", 22),
    ("Rotterdam", "Mumbai", 20),
    ("Tokyo", "Los Angeles", 36),
    ("Sydney", "Singapore", 18),
    ("Hamburg", "New York", 24),
    ("Dubai", "Mumbai", 33),
    ("Busan", "Shanghai", 29),
    ("Hong Kong", "Hamburg", 26),
]

flow_values = [f[2] for f in flows_data]
max_flow = max(flow_values)
min_flow = min(flow_values)
line_widths = [2 + 8 * (v - min_flow) / (max_flow - min_flow) for v in flow_values]

# Plot
fig = go.Figure()

# Add flow arcs using quadratic Bezier curves
for i, (origin, dest, volume) in enumerate(flows_data):
    o_lat, o_lon = cities[origin]
    d_lat, d_lon = cities[dest]

    t = np.linspace(0, 1, 50)
    mid_lat = (o_lat + d_lat) / 2
    mid_lon = (o_lon + d_lon) / 2

    dist = np.sqrt((d_lat - o_lat) ** 2 + (d_lon - o_lon) ** 2)
    arc_height = dist * 0.2
    dx = d_lon - o_lon
    dy = d_lat - o_lat
    perp_x = -dy / (dist + 0.001) * arc_height * 2
    perp_y = dx / (dist + 0.001) * arc_height * 2
    ctrl_lat = mid_lat + perp_y
    ctrl_lon = mid_lon + perp_x

    arc_lats = (1 - t) ** 2 * o_lat + 2 * (1 - t) * t * ctrl_lat + t**2 * d_lat
    arc_lons = (1 - t) ** 2 * o_lon + 2 * (1 - t) * t * ctrl_lon + t**2 * d_lon

    intensity = (volume - min_flow) / (max_flow - min_flow)
    opacity = 0.35 + 0.40 * intensity

    fig.add_trace(
        go.Scattergeo(
            lon=arc_lons,
            lat=arc_lats,
            mode="lines",
            line={"width": line_widths[i], "color": f"rgba({ARC_RGB}, {opacity:.2f})"},
            hoverinfo="text",
            hovertext=f"{origin} → {dest}<br>Volume: {volume}M tons",
            showlegend=False,
        )
    )

    # Directional arrow at ~85% along arc, rotated to match the bezier tangent
    t_a = 0.85
    a_lat = (1 - t_a) ** 2 * o_lat + 2 * (1 - t_a) * t_a * ctrl_lat + t_a**2 * d_lat
    a_lon = (1 - t_a) ** 2 * o_lon + 2 * (1 - t_a) * t_a * ctrl_lon + t_a**2 * d_lon
    dir_lat = 2 * (1 - t_a) * (ctrl_lat - o_lat) + 2 * t_a * (d_lat - ctrl_lat)
    dir_lon = 2 * (1 - t_a) * (ctrl_lon - o_lon) + 2 * t_a * (d_lon - ctrl_lon)
    arrow_angle = 90 - math.degrees(math.atan2(dir_lat, dir_lon))
    fig.add_trace(
        go.Scattergeo(
            lon=[a_lon],
            lat=[a_lat],
            mode="markers",
            marker={
                "symbol": "arrow",
                "size": max(6, int(line_widths[i] * 1.5)),
                "color": f"rgba({ARC_RGB}, {opacity:.2f})",
                "angle": arrow_angle,
            },
            hoverinfo="skip",
            showlegend=False,
        )
    )

# City markers and labels
all_cities = {o for o, _, _ in flows_data} | {d for _, d, _ in flows_data}
city_names = list(all_cities)
city_lats = [cities[c][0] for c in city_names]
city_lons = [cities[c][1] for c in city_names]

city_totals = {}
for o, d, v in flows_data:
    city_totals[o] = city_totals.get(o, 0) + v
    city_totals[d] = city_totals.get(d, 0) + v

max_total = max(city_totals.values())
marker_sizes = [10 + 15 * city_totals[c] / max_total for c in city_names]
text_pos = [label_positions.get(c, "top center") for c in city_names]

fig.add_trace(
    go.Scattergeo(
        lon=city_lons,
        lat=city_lats,
        mode="markers+text",
        marker={"size": marker_sizes, "color": CITY_COLOR, "line": {"width": 2, "color": PAGE_BG}},
        text=city_names,
        textposition=text_pos,
        textfont={"size": 11, "color": INK_SOFT},
        hoverinfo="text",
        hovertext=[f"{c}<br>Total: {city_totals[c]}M tons" for c in city_names],
        showlegend=False,
    )
)

# Style
fig.update_layout(
    title={
        "text": "flowmap-origin-destination · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK},
        "x": 0.5,
        "xanchor": "center",
    },
    geo={
        "projection_type": "natural earth",
        "showland": True,
        "landcolor": LAND_COLOR,
        "showocean": True,
        "oceancolor": OCEAN_COLOR,
        "showcoastlines": True,
        "coastlinecolor": COAST_COLOR,
        "coastlinewidth": 1,
        "showlakes": True,
        "lakecolor": OCEAN_COLOR,
        "showcountries": True,
        "countrycolor": COUNTRY_COLOR,
        "countrywidth": 0.5,
        "bgcolor": "rgba(0,0,0,0)",
    },
    paper_bgcolor=PAGE_BG,
    margin={"l": 20, "r": 20, "t": 80, "b": 40},
    annotations=[
        {
            "text": "Line width proportional to trade volume (millions of tons)",
            "x": 0.5,
            "y": -0.04,
            "xref": "paper",
            "yref": "paper",
            "showarrow": False,
            "font": {"size": 12, "color": INK_MUTED},
        }
    ],
)

# Save
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Origin-Destination Flow Map on anyplot.ai.

Other implementations