{"spec_id":"map-connection-lines","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nmap-connection-lines: Connection Lines Map (Origin-Destination)\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-28\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]  # green — connection lines (first series)\nACCENT = IMPRINT_PALETTE[2]  # blue — airport markers\n\n# Simplified continent outlines (approximate polygons for geographic context)\nCONT_LON = [\n    # Africa\n    [-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],\n    # Europe\n    [\n        -9,\n        -8,\n        -8,\n        0,\n        6,\n        8,\n        15,\n        17,\n        21,\n        28,\n        32,\n        32,\n        30,\n        32,\n        28,\n        25,\n        28,\n        20,\n        10,\n        3,\n        5,\n        8,\n        10,\n        10,\n        10,\n        5,\n        3,\n        0,\n        -2,\n        -5,\n        -5,\n        -8,\n        -9,\n    ],\n    # North America\n    [\n        -168,\n        -140,\n        -130,\n        -124,\n        -124,\n        -118,\n        -108,\n        -87,\n        -82,\n        -77,\n        -77,\n        -66,\n        -60,\n        -51,\n        -53,\n        -52,\n        -65,\n        -74,\n        -80,\n        -80,\n        -75,\n        -75,\n        -74,\n        -67,\n        -60,\n        -56,\n        -63,\n        -90,\n        -120,\n        -155,\n        -168,\n        -168,\n    ],\n    # South America\n    [\n        -77,\n        -62,\n        -51,\n        -49,\n        -35,\n        -35,\n        -39,\n        -45,\n        -48,\n        -52,\n        -55,\n        -65,\n        -68,\n        -69,\n        -68,\n        -63,\n        -55,\n        -52,\n        -52,\n        -56,\n        -65,\n        -67,\n        -70,\n        -70,\n        -75,\n        -77,\n        -77,\n    ],\n    # Asia (simplified)\n    [\n        26,\n        29,\n        36,\n        42,\n        55,\n        65,\n        80,\n        100,\n        140,\n        153,\n        150,\n        135,\n        130,\n        125,\n        122,\n        108,\n        103,\n        103,\n        110,\n        115,\n        120,\n        118,\n        100,\n        92,\n        80,\n        68,\n        60,\n        50,\n        44,\n        43,\n        42,\n        40,\n        43,\n        50,\n        50,\n        50,\n        55,\n        60,\n        70,\n        80,\n        90,\n        103,\n        112,\n        120,\n        130,\n        135,\n        142,\n        140,\n        145,\n        150,\n        135,\n        100,\n        60,\n        50,\n        38,\n        30,\n        26,\n        24,\n        26,\n        28,\n        32,\n        32,\n        26,\n    ],\n    # Australia\n    [114, 113, 114, 118, 122, 130, 133, 137, 140, 150, 153, 150, 143, 138, 130, 125, 120, 114],\n]\nCONT_LAT = [\n    # Africa\n    [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],\n    # Europe\n    [\n        36,\n        38,\n        44,\n        43,\n        44,\n        47,\n        46,\n        43,\n        40,\n        37,\n        36,\n        38,\n        40,\n        46,\n        55,\n        58,\n        64,\n        70,\n        71,\n        62,\n        58,\n        56,\n        56,\n        55,\n        52,\n        52,\n        51,\n        51,\n        49,\n        48,\n        44,\n        42,\n        36,\n    ],\n    # North America\n    [\n        72,\n        60,\n        55,\n        48,\n        34,\n        32,\n        25,\n        15,\n        9,\n        8,\n        9,\n        10,\n        5,\n        4,\n        6,\n        10,\n        17,\n        18,\n        24,\n        32,\n        35,\n        38,\n        41,\n        44,\n        47,\n        53,\n        65,\n        70,\n        75,\n        60,\n        55,\n        72,\n    ],\n    # South America\n    [\n        10,\n        10,\n        4,\n        4,\n        -5,\n        -12,\n        -18,\n        -23,\n        -28,\n        -33,\n        -36,\n        -42,\n        -48,\n        -52,\n        -55,\n        -55,\n        -53,\n        -52,\n        -45,\n        -40,\n        -36,\n        -30,\n        -25,\n        -17,\n        -10,\n        -2,\n        10,\n    ],\n    # Asia\n    [\n        42,\n        42,\n        48,\n        52,\n        62,\n        68,\n        74,\n        78,\n        73,\n        55,\n        44,\n        42,\n        35,\n        32,\n        25,\n        18,\n        2,\n        -1,\n        -8,\n        -3,\n        5,\n        8,\n        18,\n        22,\n        26,\n        22,\n        22,\n        18,\n        12,\n        8,\n        2,\n        -12,\n        -20,\n        -5,\n        12,\n        18,\n        30,\n        40,\n        38,\n        35,\n        40,\n        42,\n        43,\n        50,\n        45,\n        48,\n        52,\n        56,\n        62,\n        68,\n        72,\n        78,\n        70,\n        68,\n        68,\n        65,\n        60,\n        58,\n        56,\n        52,\n        46,\n        42,\n        42,\n    ],\n    # Australia\n    [-22, -26, -32, -35, -34, -33, -32, -36, -38, -38, -27, -22, -15, -14, -12, -17, -20, -22],\n]\n\n# Data: Major international flight routes between hub airports\nairports = {\n    \"JFK\": (40.6413, -73.7781),  # New York\n    \"LHR\": (51.4700, -0.4543),  # London\n    \"CDG\": (49.0097, 2.5479),  # Paris\n    \"DXB\": (25.2532, 55.3657),  # Dubai\n    \"SIN\": (1.3644, 103.9915),  # Singapore\n    \"HND\": (35.5494, 139.7798),  # Tokyo\n    \"SYD\": (-33.9399, 151.1753),  # Sydney\n    \"LAX\": (33.9416, -118.4085),  # Los Angeles\n    \"SFO\": (37.6213, -122.3790),  # San Francisco\n    \"ORD\": (41.9742, -87.9073),  # Chicago\n    \"FRA\": (50.0379, 8.5622),  # Frankfurt\n    \"AMS\": (52.3105, 4.7683),  # Amsterdam\n}\n\nroutes = [\n    (\"JFK\", \"LHR\", 4.5),\n    (\"JFK\", \"CDG\", 2.8),\n    (\"LAX\", \"HND\", 3.2),\n    (\"SFO\", \"SIN\", 1.5),\n    (\"LHR\", \"DXB\", 3.8),\n    (\"CDG\", \"DXB\", 2.1),\n    (\"DXB\", \"SIN\", 2.5),\n    (\"SIN\", \"SYD\", 2.9),\n    (\"LHR\", \"SYD\", 1.2),\n    (\"JFK\", \"FRA\", 2.3),\n    (\"ORD\", \"LHR\", 1.8),\n    (\"LAX\", \"SYD\", 1.6),\n    (\"FRA\", \"SIN\", 1.4),\n    (\"AMS\", \"HND\", 0.9),\n    (\"CDG\", \"HND\", 1.1),\n]\n\n# Build curved arc data (parabolic perpendicular offset in lat/lon space)\nline_xs, line_ys, line_widths, line_alphas = [], [], [], []\nroute_labels, passenger_volumes = [], []\nvolumes = [r[2] for r in routes]\nmin_vol, max_vol = min(volumes), max(volumes)\n\nfor origin, dest, volume in routes:\n    o_lat, o_lon = airports[origin]\n    d_lat, d_lon = airports[dest]\n    t = np.linspace(0, 1, 60)\n    arc_lons = o_lon + (d_lon - o_lon) * t\n    arc_lats = o_lat + (d_lat - o_lat) * t\n    dx, dy = d_lon - o_lon, d_lat - o_lat\n    dist = np.sqrt(dx**2 + dy**2)\n    mid_offset = dist * 0.15\n    curve = 4 * t * (1 - t) * mid_offset\n    if dist > 0:\n        perp_x, perp_y = -dy / dist, dx / dist\n    else:\n        perp_x, perp_y = 0.0, 0.0\n    arc_lons = arc_lons + perp_x * curve\n    arc_lats = arc_lats + perp_y * curve\n    line_xs.append(arc_lons.tolist())\n    line_ys.append(arc_lats.tolist())\n    normalized = (volume - min_vol) / (max_vol - min_vol) if max_vol > min_vol else 0.5\n    line_widths.append(5 + normalized * 9)\n    line_alphas.append(0.5 + normalized * 0.2)\n    route_labels.append(f\"{origin} → {dest}\")\n    passenger_volumes.append(f\"{volume}M pax/year\")\n\n# Airport marker data\nairport_names = list(airports.keys())\nairport_lons = [airports[a][1] for a in airport_names]\nairport_lats = [airports[a][0] for a in airport_names]\n\n# Per-airport label offsets — push European cluster apart to prevent overlap\nLABEL_OFFSETS = {\n    \"LHR\": (-65, 8),  # west of dot; avoids CDG/AMS cluster to the right\n    \"AMS\": (14, 22),  # above default; AMS is the northernmost hub\n    \"CDG\": (14, -22),  # below default; CDG is the southernmost hub\n    \"FRA\": (20, 8),  # slightly further right than default\n}\nairport_x_offsets = [LABEL_OFFSETS.get(a, (14, 8))[0] for a in airport_names]\nairport_y_offsets = [LABEL_OFFSETS.get(a, (14, 8))[1] for a in airport_names]\n\n# Data sources\nline_source = ColumnDataSource(\n    data={\n        \"xs\": line_xs,\n        \"ys\": line_ys,\n        \"line_width\": line_widths,\n        \"alpha\": line_alphas,\n        \"route\": route_labels,\n        \"passengers\": passenger_volumes,\n    }\n)\nairport_source = ColumnDataSource(\n    data={\n        \"x\": airport_lons,\n        \"y\": airport_lats,\n        \"name\": airport_names,\n        \"x_off\": airport_x_offsets,\n        \"y_off\": airport_y_offsets,\n    }\n)\ncontinent_source = ColumnDataSource(data={\"xs\": CONT_LON, \"ys\": CONT_LAT})\n\n# Title font size (scales linearly if title exceeds 67-char baseline)\ntitle = \"map-connection-lines · python · bokeh · anyplot.ai\"\nn = len(title)\ntitle_fs = f\"{round(50 * (67 / n if n > 67 else 1.0))}pt\"\n\n# Figure — width/height are the exact PNG canvas dimensions\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_range=(-180, 180),\n    y_range=(-60, 80),\n    x_axis_label=\"Longitude (°)\",\n    y_axis_label=\"Latitude (°)\",\n    toolbar_location=None,  # omit toolbar so PNG = exactly height=1800 px\n    min_border_bottom=160,  # room for 34pt tick labels + 42pt x-axis label\n    min_border_left=180,  # room for 34pt tick labels + 42pt y-axis label\n    min_border_top=110,  # room for 50pt title\n    min_border_right=50,\n)\n\n# Continent base map\np.patches(\n    xs=\"xs\",\n    ys=\"ys\",\n    source=continent_source,\n    fill_color=INK_MUTED,\n    fill_alpha=0.18,\n    line_color=INK_SOFT,\n    line_width=0.8,\n    line_alpha=0.5,\n)\n\n# Connection arcs — line width and alpha encode passenger volume\nlines_renderer = p.multi_line(\n    xs=\"xs\",\n    ys=\"ys\",\n    source=line_source,\n    line_width=\"line_width\",\n    line_alpha=\"alpha\",\n    line_color=BRAND,\n    line_cap=\"round\",\n)\np.add_tools(\n    HoverTool(\n        renderers=[lines_renderer], tooltips=[(\"Route\", \"@route\"), (\"Traffic\", \"@passengers\")], line_policy=\"interp\"\n    )\n)\n\n# Airport markers\np.scatter(x=\"x\", y=\"y\", source=airport_source, size=18, color=ACCENT, line_color=PAGE_BG, line_width=2, alpha=0.9)\np.text(\n    x=\"x\",\n    y=\"y\",\n    text=\"name\",\n    source=airport_source,\n    x_offset=\"x_off\",\n    y_offset=\"y_off\",\n    text_font_size=\"22pt\",\n    text_color=INK,\n    text_font_style=\"bold\",\n)\n\n# Manual legend (upper-left Pacific — clear of all route clusters)\nlx0, lx1 = -175, -147\nly_title, ly = 77, [71, 65, 59]\np.multi_line(xs=[[lx0, lx1]], ys=[[ly[0], ly[0]]], line_width=4, line_color=BRAND, line_alpha=0.4)\np.multi_line(xs=[[lx0, lx1]], ys=[[ly[1], ly[1]]], line_width=9, line_color=BRAND, line_alpha=0.55)\np.multi_line(xs=[[lx0, lx1]], ys=[[ly[2], ly[2]]], line_width=14, line_color=BRAND, line_alpha=0.7)\np.text(x=[lx1 + 2], y=[ly[0]], text=[\"Low (~1M pax/yr)\"], text_font_size=\"24pt\", text_color=INK, text_baseline=\"middle\")\np.text(\n    x=[lx1 + 2], y=[ly[1]], text=[\"Med (~2.5M pax/yr)\"], text_font_size=\"24pt\", text_color=INK, text_baseline=\"middle\"\n)\np.text(\n    x=[lx1 + 2], y=[ly[2]], text=[\"High (~4.5M pax/yr)\"], text_font_size=\"24pt\", text_color=INK, text_baseline=\"middle\"\n)\np.text(x=[lx0], y=[ly_title], text=[\"Passenger Volume\"], text_font_size=\"26pt\", text_color=INK, text_font_style=\"bold\")\n\n# Theme-adaptive chrome\np.title.text_font_size = title_fs\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.1\np.ygrid.grid_line_alpha = 0.1\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\n# Save interactive HTML (required catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome via Selenium\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\n# Force exact viewport to W×H regardless of headless browser chrome overhead\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}