{"spec_id":"flowmap-origin-destination","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nflowmap-origin-destination: Origin-Destination Flow Map\nLibrary: plotnine 0.15.4 | Python 3.13.13\nQuality: 83/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    coord_fixed,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_path,\n    geom_point,\n    geom_polygon,\n    geom_text,\n    ggplot,\n    labs,\n    scale_color_cmap,\n    scale_size_identity,\n    theme,\n    theme_minimal,\n)\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nOCEAN_BG = \"#DDE9F2\" if THEME == \"light\" else \"#111A22\"\nLAND_FILL = \"#D0CCC4\" if THEME == \"light\" else \"#363632\"\nLAND_COLOR = \"#AEA9A1\" if THEME == \"light\" else \"#525248\"\n\n# Major world cities (lat, lon)\nlocations = {\n    \"New York\": (40.71, -74.01),\n    \"London\": (51.51, -0.13),\n    \"Paris\": (48.85, 2.35),\n    \"Dubai\": (25.20, 55.27),\n    \"Sydney\": (-33.87, 151.21),\n    \"Toronto\": (43.65, -79.38),\n    \"Singapore\": (1.35, 103.82),\n    \"Tokyo\": (35.68, 139.69),\n    \"São Paulo\": (-23.55, -46.63),\n    \"Mumbai\": (19.08, 72.88),\n    \"Lagos\": (6.45, 3.40),\n    \"Cairo\": (30.04, 31.24),\n    \"Berlin\": (52.52, 13.40),\n    \"Los Angeles\": (34.05, -118.24),\n}\n\n# International migration flows (thousands of people per year)\nflows_data = [\n    (\"Mumbai\", \"Dubai\", 142),\n    (\"Lagos\", \"London\", 98),\n    (\"São Paulo\", \"New York\", 85),\n    (\"Cairo\", \"Dubai\", 78),\n    (\"Tokyo\", \"Los Angeles\", 72),\n    (\"London\", \"Sydney\", 68),\n    (\"Mumbai\", \"London\", 65),\n    (\"New York\", \"Toronto\", 60),\n    (\"Paris\", \"London\", 55),\n    (\"Singapore\", \"Sydney\", 48),\n    (\"Lagos\", \"Paris\", 44),\n    (\"Berlin\", \"London\", 40),\n    (\"Cairo\", \"London\", 38),\n    (\"Tokyo\", \"Sydney\", 35),\n    (\"Mumbai\", \"Singapore\", 32),\n    (\"São Paulo\", \"London\", 30),\n    (\"Lagos\", \"Dubai\", 27),\n    (\"Toronto\", \"London\", 24),\n]\n\n# Build flow path data with inline quadratic Bezier curve computation\nflow_paths = []\nflow_values = [f for _, _, f in flows_data]\nmin_flow = float(min(flow_values))\nmax_flow = float(max(flow_values))\n\nfor i, (origin, dest, flow) in enumerate(flows_data):\n    origin_lat, origin_lon = locations[origin]\n    dest_lat, dest_lon = locations[dest]\n\n    mid_x = (origin_lon + dest_lon) / 2\n    mid_y = (origin_lat + dest_lat) / 2\n    dx = dest_lon - origin_lon\n    dy = dest_lat - origin_lat\n    seg_len = np.sqrt(dx**2 + dy**2)\n    perp_x = -dy / seg_len if seg_len > 0 else 0.0\n    perp_y = dx / seg_len if seg_len > 0 else 0.0\n    ctrl_x = mid_x + perp_x * seg_len * 0.25\n    ctrl_y = mid_y + perp_y * seg_len * 0.25\n\n    line_width = 0.3 + ((flow - min_flow) / (max_flow - min_flow)) * 2.2\n    t = np.linspace(0, 1, 40)\n    curve_x = (1 - t) ** 2 * origin_lon + 2 * (1 - t) * t * ctrl_x + t**2 * dest_lon\n    curve_y = (1 - t) ** 2 * origin_lat + 2 * (1 - t) * t * ctrl_y + t**2 * dest_lat\n\n    for j in range(len(t)):\n        flow_paths.append(\n            {\"flow_id\": i, \"order\": j, \"x\": curve_x[j], \"y\": curve_y[j], \"flow\": float(flow), \"size\": line_width}\n        )\n\ndf_flows = pd.DataFrame(flow_paths)\n\n# Per-city label nudge (degrees) to separate the dense Western Europe cluster\nLABEL_NUDGE = {\n    \"London\": (4, 3.0),  # nudge up to clear Paris\n    \"Paris\": (4, -3.0),  # nudge down to clear London/Berlin\n    \"Berlin\": (4, 2.5),  # nudge up, east of Paris so less conflict\n    \"Toronto\": (4, 2.0),  # nudge up (near New York)\n    \"New York\": (4, -2.0),  # nudge down (near Toronto)\n}\nDEFAULT_NUDGE = (4, 0)\n\nlocation_points = []\nfor name, (lat, lon) in locations.items():\n    nx, ny = LABEL_NUDGE.get(name, DEFAULT_NUDGE)\n    location_points.append({\"name\": name, \"lat\": lat, \"lon\": lon, \"lx\": lon + nx, \"ly\": lat + ny})\ndf_locations = pd.DataFrame(location_points)\n\n# Simplified continent outlines for basemap\ncontinents = []\n\n# North America\nna_lon = [\n    -170,\n    -168,\n    -140,\n    -125,\n    -124,\n    -117,\n    -105,\n    -97,\n    -82,\n    -77,\n    -68,\n    -55,\n    -52,\n    -80,\n    -87,\n    -97,\n    -105,\n    -125,\n    -145,\n    -165,\n    -170,\n]\nna_lat = [60, 65, 70, 55, 48, 33, 25, 26, 25, 35, 45, 48, 45, 27, 30, 20, 22, 50, 60, 55, 60]\nfor i in range(len(na_lon)):\n    continents.append({\"continent\": \"N. America\", \"order\": i, \"lon\": na_lon[i], \"lat\": na_lat[i]})\n\n# South America\nsa_lon = [-80, -68, -60, -50, -35, -40, -50, -55, -68, -72, -75, -80, -82, -80]\nsa_lat = [10, 12, 5, 0, -5, -22, -35, -52, -55, -18, -5, 0, 8, 10]\nfor i in range(len(sa_lon)):\n    continents.append({\"continent\": \"S. America\", \"order\": i, \"lon\": sa_lon[i], \"lat\": sa_lat[i]})\n\n# Europe\neu_lon = [-10, 0, 10, 20, 30, 40, 50, 60, 50, 35, 25, 20, 10, 0, -10, -10]\neu_lat = [35, 37, 36, 35, 35, 40, 45, 55, 70, 70, 70, 65, 60, 50, 40, 35]\nfor i in range(len(eu_lon)):\n    continents.append({\"continent\": \"Europe\", \"order\": i, \"lon\": eu_lon[i], \"lat\": eu_lat[i]})\n\n# Africa\naf_lon = [-17, -5, 10, 35, 50, 52, 43, 35, 30, 15, 0, -17, -17]\naf_lat = [15, 37, 37, 32, 12, 0, -25, -35, -35, -25, 5, 20, 15]\nfor i in range(len(af_lon)):\n    continents.append({\"continent\": \"Africa\", \"order\": i, \"lon\": af_lon[i], \"lat\": af_lat[i]})\n\n# Asia\nas_lon = [60, 80, 100, 120, 140, 145, 140, 130, 105, 100, 80, 60, 45, 30, 25, 30, 35, 50, 60]\nas_lat = [55, 70, 75, 70, 55, 45, 35, 30, 0, 5, 10, 25, 30, 35, 42, 55, 70, 70, 55]\nfor i in range(len(as_lon)):\n    continents.append({\"continent\": \"Asia\", \"order\": i, \"lon\": as_lon[i], \"lat\": as_lat[i]})\n\n# Australia/Oceania\nau_lon = [113, 125, 135, 145, 152, 150, 140, 130, 115, 113]\nau_lat = [-22, -15, -12, -15, -25, -38, -38, -33, -35, -22]\nfor i in range(len(au_lon)):\n    continents.append({\"continent\": \"Australia\", \"order\": i, \"lon\": au_lon[i], \"lat\": au_lat[i]})\n\ndf_continents = pd.DataFrame(continents)\n\n# Build the origin-destination flow map\nplot = (\n    ggplot()\n    + geom_polygon(\n        aes(x=\"lon\", y=\"lat\", group=\"continent\"),\n        data=df_continents,\n        fill=LAND_FILL,\n        color=LAND_COLOR,\n        size=0.3,\n        alpha=0.7,\n    )\n    + geom_path(\n        aes(x=\"x\", y=\"y\", group=\"flow_id\", color=\"flow\", size=\"size\"), data=df_flows, alpha=0.55, lineend=\"round\"\n    )\n    + geom_point(aes(x=\"lon\", y=\"lat\"), data=df_locations, color=\"#009E73\", size=3.0, alpha=0.9)\n    + geom_text(aes(x=\"lx\", y=\"ly\", label=\"name\"), data=df_locations, color=INK, size=8, ha=\"left\")\n    + scale_size_identity()\n    + scale_color_cmap(cmap_name=\"viridis\", name=\"Annual\\nmigrants (k)\", limits=(min_flow, max_flow))\n    + coord_fixed(ratio=1.3, xlim=(-180, 180), ylim=(-60, 80))\n    + labs(title=\"flowmap-origin-destination · python · plotnine · anyplot.ai\", x=\"Longitude (°)\", y=\"Latitude (°)\")\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=OCEAN_BG),\n        panel_grid_major=element_line(color=INK, size=0.2, alpha=0.08),\n        panel_grid_minor=element_blank(),\n        axis_line=element_line(color=INK_SOFT),\n        plot_title=element_text(size=12, color=INK, weight=\"bold\"),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        legend_title=element_text(size=8, color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_position=\"right\",\n    )\n)\n\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\", verbose=False)\n"}