Connection Lines Map (Origin-Destination) — Bokeh

A geographic map visualization showing connection lines (arcs or great circles) between pairs of locations. This plot excels at revealing spatial patterns in flows, routes, and relationships between places. The curved lines naturally represent shortest paths on Earth's surface while avoiding visual overlap with straight lines. Line properties like thickness or color can encode additional variables such as traffic volume or connection type.

Connection Lines Map (Origin-Destination) rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
map-connection-lines: Connection Lines Map (Origin-Destination)
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-28
"""

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


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

IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
BRAND = IMPRINT_PALETTE[0]  # green — connection lines (first series)
ACCENT = IMPRINT_PALETTE[2]  # blue — airport markers

# Simplified continent outlines (approximate polygons for geographic context)
CONT_LON = [
    # Africa
    [-18, -17, -14, -9, 0, 12, 25, 33, 36, 44, 51, 42, 40, 36, 32, 26, 18, 12, 9, 9, 2, -5, -12, -16, -18],
    # Europe
    [
        -9,
        -8,
        -8,
        0,
        6,
        8,
        15,
        17,
        21,
        28,
        32,
        32,
        30,
        32,
        28,
        25,
        28,
        20,
        10,
        3,
        5,
        8,
        10,
        10,
        10,
        5,
        3,
        0,
        -2,
        -5,
        -5,
        -8,
        -9,
    ],
    # North America
    [
        -168,
        -140,
        -130,
        -124,
        -124,
        -118,
        -108,
        -87,
        -82,
        -77,
        -77,
        -66,
        -60,
        -51,
        -53,
        -52,
        -65,
        -74,
        -80,
        -80,
        -75,
        -75,
        -74,
        -67,
        -60,
        -56,
        -63,
        -90,
        -120,
        -155,
        -168,
        -168,
    ],
    # South America
    [
        -77,
        -62,
        -51,
        -49,
        -35,
        -35,
        -39,
        -45,
        -48,
        -52,
        -55,
        -65,
        -68,
        -69,
        -68,
        -63,
        -55,
        -52,
        -52,
        -56,
        -65,
        -67,
        -70,
        -70,
        -75,
        -77,
        -77,
    ],
    # Asia (simplified)
    [
        26,
        29,
        36,
        42,
        55,
        65,
        80,
        100,
        140,
        153,
        150,
        135,
        130,
        125,
        122,
        108,
        103,
        103,
        110,
        115,
        120,
        118,
        100,
        92,
        80,
        68,
        60,
        50,
        44,
        43,
        42,
        40,
        43,
        50,
        50,
        50,
        55,
        60,
        70,
        80,
        90,
        103,
        112,
        120,
        130,
        135,
        142,
        140,
        145,
        150,
        135,
        100,
        60,
        50,
        38,
        30,
        26,
        24,
        26,
        28,
        32,
        32,
        26,
    ],
    # Australia
    [114, 113, 114, 118, 122, 130, 133, 137, 140, 150, 153, 150, 143, 138, 130, 125, 120, 114],
]
CONT_LAT = [
    # Africa
    [14, 20, 24, 30, 32, 37, 37, 30, 22, 12, 12, 2, -10, -18, -26, -34, -35, -25, -12, -3, 5, 5, 4, 12, 14],
    # Europe
    [
        36,
        38,
        44,
        43,
        44,
        47,
        46,
        43,
        40,
        37,
        36,
        38,
        40,
        46,
        55,
        58,
        64,
        70,
        71,
        62,
        58,
        56,
        56,
        55,
        52,
        52,
        51,
        51,
        49,
        48,
        44,
        42,
        36,
    ],
    # North America
    [
        72,
        60,
        55,
        48,
        34,
        32,
        25,
        15,
        9,
        8,
        9,
        10,
        5,
        4,
        6,
        10,
        17,
        18,
        24,
        32,
        35,
        38,
        41,
        44,
        47,
        53,
        65,
        70,
        75,
        60,
        55,
        72,
    ],
    # South America
    [
        10,
        10,
        4,
        4,
        -5,
        -12,
        -18,
        -23,
        -28,
        -33,
        -36,
        -42,
        -48,
        -52,
        -55,
        -55,
        -53,
        -52,
        -45,
        -40,
        -36,
        -30,
        -25,
        -17,
        -10,
        -2,
        10,
    ],
    # Asia
    [
        42,
        42,
        48,
        52,
        62,
        68,
        74,
        78,
        73,
        55,
        44,
        42,
        35,
        32,
        25,
        18,
        2,
        -1,
        -8,
        -3,
        5,
        8,
        18,
        22,
        26,
        22,
        22,
        18,
        12,
        8,
        2,
        -12,
        -20,
        -5,
        12,
        18,
        30,
        40,
        38,
        35,
        40,
        42,
        43,
        50,
        45,
        48,
        52,
        56,
        62,
        68,
        72,
        78,
        70,
        68,
        68,
        65,
        60,
        58,
        56,
        52,
        46,
        42,
        42,
    ],
    # Australia
    [-22, -26, -32, -35, -34, -33, -32, -36, -38, -38, -27, -22, -15, -14, -12, -17, -20, -22],
]

# Data: Major international flight routes between hub airports
airports = {
    "JFK": (40.6413, -73.7781),  # New York
    "LHR": (51.4700, -0.4543),  # London
    "CDG": (49.0097, 2.5479),  # Paris
    "DXB": (25.2532, 55.3657),  # Dubai
    "SIN": (1.3644, 103.9915),  # Singapore
    "HND": (35.5494, 139.7798),  # Tokyo
    "SYD": (-33.9399, 151.1753),  # Sydney
    "LAX": (33.9416, -118.4085),  # Los Angeles
    "SFO": (37.6213, -122.3790),  # San Francisco
    "ORD": (41.9742, -87.9073),  # Chicago
    "FRA": (50.0379, 8.5622),  # Frankfurt
    "AMS": (52.3105, 4.7683),  # Amsterdam
}

routes = [
    ("JFK", "LHR", 4.5),
    ("JFK", "CDG", 2.8),
    ("LAX", "HND", 3.2),
    ("SFO", "SIN", 1.5),
    ("LHR", "DXB", 3.8),
    ("CDG", "DXB", 2.1),
    ("DXB", "SIN", 2.5),
    ("SIN", "SYD", 2.9),
    ("LHR", "SYD", 1.2),
    ("JFK", "FRA", 2.3),
    ("ORD", "LHR", 1.8),
    ("LAX", "SYD", 1.6),
    ("FRA", "SIN", 1.4),
    ("AMS", "HND", 0.9),
    ("CDG", "HND", 1.1),
]

# Build curved arc data (parabolic perpendicular offset in lat/lon space)
line_xs, line_ys, line_widths, line_alphas = [], [], [], []
route_labels, passenger_volumes = [], []
volumes = [r[2] for r in routes]
min_vol, max_vol = min(volumes), max(volumes)

for origin, dest, volume in routes:
    o_lat, o_lon = airports[origin]
    d_lat, d_lon = airports[dest]
    t = np.linspace(0, 1, 60)
    arc_lons = o_lon + (d_lon - o_lon) * t
    arc_lats = o_lat + (d_lat - o_lat) * t
    dx, dy = d_lon - o_lon, d_lat - o_lat
    dist = np.sqrt(dx**2 + dy**2)
    mid_offset = dist * 0.15
    curve = 4 * t * (1 - t) * mid_offset
    if dist > 0:
        perp_x, perp_y = -dy / dist, dx / dist
    else:
        perp_x, perp_y = 0.0, 0.0
    arc_lons = arc_lons + perp_x * curve
    arc_lats = arc_lats + perp_y * curve
    line_xs.append(arc_lons.tolist())
    line_ys.append(arc_lats.tolist())
    normalized = (volume - min_vol) / (max_vol - min_vol) if max_vol > min_vol else 0.5
    line_widths.append(5 + normalized * 9)
    line_alphas.append(0.5 + normalized * 0.2)
    route_labels.append(f"{origin} → {dest}")
    passenger_volumes.append(f"{volume}M pax/year")

# Airport marker data
airport_names = list(airports.keys())
airport_lons = [airports[a][1] for a in airport_names]
airport_lats = [airports[a][0] for a in airport_names]

# Per-airport label offsets — push European cluster apart to prevent overlap
LABEL_OFFSETS = {
    "LHR": (-65, 8),  # west of dot; avoids CDG/AMS cluster to the right
    "AMS": (14, 22),  # above default; AMS is the northernmost hub
    "CDG": (14, -22),  # below default; CDG is the southernmost hub
    "FRA": (20, 8),  # slightly further right than default
}
airport_x_offsets = [LABEL_OFFSETS.get(a, (14, 8))[0] for a in airport_names]
airport_y_offsets = [LABEL_OFFSETS.get(a, (14, 8))[1] for a in airport_names]

# Data sources
line_source = ColumnDataSource(
    data={
        "xs": line_xs,
        "ys": line_ys,
        "line_width": line_widths,
        "alpha": line_alphas,
        "route": route_labels,
        "passengers": passenger_volumes,
    }
)
airport_source = ColumnDataSource(
    data={
        "x": airport_lons,
        "y": airport_lats,
        "name": airport_names,
        "x_off": airport_x_offsets,
        "y_off": airport_y_offsets,
    }
)
continent_source = ColumnDataSource(data={"xs": CONT_LON, "ys": CONT_LAT})

# Title font size (scales linearly if title exceeds 67-char baseline)
title = "map-connection-lines · python · bokeh · anyplot.ai"
n = len(title)
title_fs = f"{round(50 * (67 / n if n > 67 else 1.0))}pt"

# Figure — width/height are the exact PNG canvas dimensions
p = figure(
    width=3200,
    height=1800,
    title=title,
    x_range=(-180, 180),
    y_range=(-60, 80),
    x_axis_label="Longitude (°)",
    y_axis_label="Latitude (°)",
    toolbar_location=None,  # omit toolbar so PNG = exactly height=1800 px
    min_border_bottom=160,  # room for 34pt tick labels + 42pt x-axis label
    min_border_left=180,  # room for 34pt tick labels + 42pt y-axis label
    min_border_top=110,  # room for 50pt title
    min_border_right=50,
)

# Continent base map
p.patches(
    xs="xs",
    ys="ys",
    source=continent_source,
    fill_color=INK_MUTED,
    fill_alpha=0.18,
    line_color=INK_SOFT,
    line_width=0.8,
    line_alpha=0.5,
)

# Connection arcs — line width and alpha encode passenger volume
lines_renderer = p.multi_line(
    xs="xs",
    ys="ys",
    source=line_source,
    line_width="line_width",
    line_alpha="alpha",
    line_color=BRAND,
    line_cap="round",
)
p.add_tools(
    HoverTool(
        renderers=[lines_renderer], tooltips=[("Route", "@route"), ("Traffic", "@passengers")], line_policy="interp"
    )
)

# Airport markers
p.scatter(x="x", y="y", source=airport_source, size=18, color=ACCENT, line_color=PAGE_BG, line_width=2, alpha=0.9)
p.text(
    x="x",
    y="y",
    text="name",
    source=airport_source,
    x_offset="x_off",
    y_offset="y_off",
    text_font_size="22pt",
    text_color=INK,
    text_font_style="bold",
)

# Manual legend (upper-left Pacific — clear of all route clusters)
lx0, lx1 = -175, -147
ly_title, ly = 77, [71, 65, 59]
p.multi_line(xs=[[lx0, lx1]], ys=[[ly[0], ly[0]]], line_width=4, line_color=BRAND, line_alpha=0.4)
p.multi_line(xs=[[lx0, lx1]], ys=[[ly[1], ly[1]]], line_width=9, line_color=BRAND, line_alpha=0.55)
p.multi_line(xs=[[lx0, lx1]], ys=[[ly[2], ly[2]]], line_width=14, line_color=BRAND, line_alpha=0.7)
p.text(x=[lx1 + 2], y=[ly[0]], text=["Low (~1M pax/yr)"], text_font_size="24pt", text_color=INK, text_baseline="middle")
p.text(
    x=[lx1 + 2], y=[ly[1]], text=["Med (~2.5M pax/yr)"], text_font_size="24pt", text_color=INK, text_baseline="middle"
)
p.text(
    x=[lx1 + 2], y=[ly[2]], text=["High (~4.5M pax/yr)"], text_font_size="24pt", text_color=INK, text_baseline="middle"
)
p.text(x=[lx0], y=[ly_title], text=["Passenger Volume"], text_font_size="26pt", text_color=INK, text_font_style="bold")

# Theme-adaptive chrome
p.title.text_font_size = title_fs
p.title.text_color = INK
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.xgrid.grid_line_color = INK
p.ygrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.1
p.ygrid.grid_line_alpha = 0.1
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = INK_SOFT

# Save interactive HTML (required catalog artifact)
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome via Selenium
W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
# Force exact viewport to W×H regardless of headless browser chrome overhead
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Connection Lines Map (Origin-Destination) on anyplot.ai.

Other implementations