{"spec_id":"flowmap-origin-destination","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nflowmap-origin-destination: Origin-Destination Flow Map\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\n\n\n# Theme tokens\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\"\nMAP_FILL = \"#D8D6CE\" if THEME == \"light\" else \"#2D2D2A\"\n\n# Data: Migration flows between major European cities\nnp.random.seed(42)\n\ncities = {\n    \"London\": (51.5074, -0.1278),\n    \"Paris\": (48.8566, 2.3522),\n    \"Berlin\": (52.5200, 13.4050),\n    \"Madrid\": (40.4168, -3.7038),\n    \"Rome\": (41.9028, 12.4964),\n    \"Amsterdam\": (52.3676, 4.9041),\n    \"Vienna\": (48.2082, 16.3738),\n    \"Brussels\": (50.8503, 4.3517),\n    \"Lisbon\": (38.7223, -9.1393),\n    \"Dublin\": (53.3498, -6.2603),\n}\n\nflows = []\ncity_names = list(cities.keys())\nhub_cities = [\"London\", \"Paris\", \"Berlin\"]\n\nfor hub in hub_cities:\n    for dest in city_names:\n        if hub != dest:\n            flows.append(\n                {\n                    \"origin\": hub,\n                    \"origin_lat\": cities[hub][0],\n                    \"origin_lon\": cities[hub][1],\n                    \"dest\": dest,\n                    \"dest_lat\": cities[dest][0],\n                    \"dest_lon\": cities[dest][1],\n                    \"flow\": np.random.randint(5000, 50000),\n                }\n            )\n\nfor origin in [\"Madrid\", \"Rome\", \"Amsterdam\"]:\n    for dest in hub_cities:\n        flows.append(\n            {\n                \"origin\": origin,\n                \"origin_lat\": cities[origin][0],\n                \"origin_lon\": cities[origin][1],\n                \"dest\": dest,\n                \"dest_lat\": cities[dest][0],\n                \"dest_lon\": cities[dest][1],\n                \"flow\": np.random.randint(2000, 20000),\n            }\n        )\n\ndf_flows = pd.DataFrame(flows)\ndf_cities = pd.DataFrame([{\"city\": n, \"lat\": c[0], \"lon\": c[1]} for n, c in cities.items()])\n\n# Generate Bezier arc paths; forward/reverse pairs curve in opposite directions\n# to reduce overlap in dense corridors like London–Paris–Berlin\narc_data = []\nfor _, row in df_flows.iterrows():\n    n_points = 50\n    t = np.linspace(0, 1, n_points)\n    x0, y0 = row[\"origin_lon\"], row[\"origin_lat\"]\n    x1, y1 = row[\"dest_lon\"], row[\"dest_lat\"]\n    mid_x, mid_y = (x0 + x1) / 2, (y0 + y1) / 2\n    dx, dy = x1 - x0, y1 - y0\n\n    # Deterministic sign: alphabetically first city curves one way, reverse curves other\n    sign = 1 if sorted([row[\"origin\"], row[\"dest\"]])[0] == row[\"origin\"] else -1\n    ctrl_x = mid_x - dy * 0.3 * sign\n    ctrl_y = mid_y + dx * 0.3 * sign\n\n    x_c = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * ctrl_x + t**2 * x1\n    y_c = (1 - t) ** 2 * y0 + 2 * (1 - t) * t * ctrl_y + t**2 * y1\n\n    fid = f\"{row['origin']}-{row['dest']}\"\n    for j in range(n_points):\n        arc_data.append(\n            {\n                \"flow_id\": fid,\n                \"order\": j,\n                \"lon\": x_c[j],\n                \"lat\": y_c[j],\n                \"flow\": row[\"flow\"],\n                \"origin\": row[\"origin\"],\n                \"dest\": row[\"dest\"],\n            }\n        )\n\ndf_arcs = pd.DataFrame(arc_data)\nmax_flow, min_flow = df_flows[\"flow\"].max(), df_flows[\"flow\"].min()\ndf_arcs[\"stroke_width\"] = 0.5 + 5.5 * (df_arcs[\"flow\"] - min_flow) / (max_flow - min_flow)\n\n# Per-city label groups to avoid crowding in the dense London/Paris/Brussels/Amsterdam cluster\n# Brussels placed below its dot; London/Paris/Amsterdam offset horizontally\ndense_cities = {\"London\", \"Paris\", \"Amsterdam\", \"Brussels\"}\ndf_labels_normal = df_cities[~df_cities[\"city\"].isin(dense_cities)]\ndf_london = df_cities[df_cities[\"city\"] == \"London\"]\ndf_paris = df_cities[df_cities[\"city\"] == \"Paris\"]\ndf_amsterdam = df_cities[df_cities[\"city\"] == \"Amsterdam\"]\ndf_brussels = df_cities[df_cities[\"city\"] == \"Brussels\"]\n\n# Plot — geographic projection centered on Europe, tightened to fill canvas\nworld_url = \"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json\"\nworld = alt.topo_feature(world_url, \"countries\")\nproj = {\"type\": \"mercator\", \"scale\": 440, \"center\": [6, 49], \"clipExtent\": [[0, 0], [800, 450]]}\n\nbase = (\n    alt.Chart(world)\n    .mark_geoshape(fill=MAP_FILL, stroke=PAGE_BG, strokeWidth=0.5)\n    .project(**proj)\n    .properties(width=800, height=450)\n)\n\narcs = (\n    alt.Chart(df_arcs)\n    .mark_line(opacity=0.65, strokeCap=\"round\")\n    .encode(\n        longitude=\"lon:Q\",\n        latitude=\"lat:Q\",\n        detail=\"flow_id:N\",\n        order=\"order:O\",\n        strokeWidth=alt.StrokeWidth(\"stroke_width:Q\", scale=None, legend=None),\n        color=alt.Color(\n            \"flow:Q\",\n            scale=alt.Scale(scheme=\"blues\", domain=[min_flow, max_flow]),\n            legend=alt.Legend(title=\"Flow Volume\", titleFontSize=14, labelFontSize=12, orient=\"bottom-left\", offset=10),\n        ),\n        tooltip=[\"origin:N\", \"dest:N\", \"flow:Q\"],\n    )\n    .project(**proj)\n)\n\npoints = (\n    alt.Chart(df_cities)\n    .mark_circle(size=150, color=\"#009E73\", stroke=PAGE_BG, strokeWidth=2)\n    .encode(longitude=\"lon:Q\", latitude=\"lat:Q\", tooltip=[\"city:N\"])\n    .project(**proj)\n)\n\nlbl_kw = {\"fontSize\": 11, \"fontWeight\": \"bold\", \"color\": INK}\nlbl_enc = {\"longitude\": \"lon:Q\", \"latitude\": \"lat:Q\", \"text\": \"city:N\"}\n\nlabels_normal = alt.Chart(df_labels_normal).mark_text(dy=-14, **lbl_kw).encode(**lbl_enc).project(**proj)\nlabels_london = alt.Chart(df_london).mark_text(dx=-15, dy=-14, **lbl_kw).encode(**lbl_enc).project(**proj)\nlabels_paris = alt.Chart(df_paris).mark_text(dx=-12, dy=-14, **lbl_kw).encode(**lbl_enc).project(**proj)\nlabels_amsterdam = alt.Chart(df_amsterdam).mark_text(dx=14, dy=-14, **lbl_kw).encode(**lbl_enc).project(**proj)\nlabels_brussels = alt.Chart(df_brussels).mark_text(dx=16, dy=12, **lbl_kw).encode(**lbl_enc).project(**proj)\n\nchart = (\n    (base + arcs + points + labels_normal + labels_london + labels_paris + labels_amsterdam + labels_brussels)\n    .properties(\n        title=alt.Title(\n            \"flowmap-origin-destination · python · altair · anyplot.ai\", fontSize=16, anchor=\"start\", offset=10\n        ),\n        background=PAGE_BG,\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_title(color=INK)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=12,\n        titleFontSize=14,\n    )\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n"}