{"spec_id":"flowmap-origin-destination","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nflowmap-origin-destination: Origin-Destination Flow Map\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# This file is named bokeh.py — remove its directory from sys.path so that\n# `import bokeh` resolves to the installed package, not this file itself.\nsys.path = [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]\n\nimport numpy as np\nimport pandas as pd\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColorBar, ColumnDataSource, HoverTool, LinearColorMapper, WMTSTileSource\nfrom bokeh.palettes import Viridis256\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\"\n\n# Data: Global maritime trade flows between major ports\nnp.random.seed(42)\n\nports = {\n    \"Shanghai\": (31.2304, 121.4737),\n    \"Singapore\": (1.3521, 103.8198),\n    \"Rotterdam\": (51.9225, 4.4792),\n    \"Los Angeles\": (33.7490, -118.2437),\n    \"Dubai\": (25.2048, 55.2708),\n    \"Hong Kong\": (22.3193, 114.1694),\n    \"Hamburg\": (53.5511, 9.9937),\n    \"New York\": (40.7128, -74.0060),\n    \"Tokyo\": (35.6762, 139.6503),\n    \"Sydney\": (-33.8688, 151.2093),\n}\n\nflow_pairs = [\n    (\"Shanghai\", \"Los Angeles\", 850),\n    (\"Shanghai\", \"Rotterdam\", 720),\n    (\"Singapore\", \"Rotterdam\", 580),\n    (\"Singapore\", \"Dubai\", 490),\n    (\"Hong Kong\", \"Los Angeles\", 620),\n    (\"Hong Kong\", \"Hamburg\", 410),\n    (\"Rotterdam\", \"New York\", 530),\n    (\"Dubai\", \"Singapore\", 380),\n    (\"Tokyo\", \"Los Angeles\", 560),\n    (\"Tokyo\", \"Shanghai\", 470),\n    (\"Shanghai\", \"Singapore\", 650),\n    (\"Los Angeles\", \"New York\", 420),\n    (\"Sydney\", \"Singapore\", 340),\n    (\"Sydney\", \"Shanghai\", 290),\n    (\"Hamburg\", \"New York\", 310),\n    (\"Rotterdam\", \"Dubai\", 280),\n    (\"New York\", \"Rotterdam\", 390),\n    (\"Dubai\", \"Hamburg\", 260),\n]\n\ndf = pd.DataFrame(\n    [\n        {\n            \"origin_name\": origin,\n            \"dest_name\": dest,\n            \"origin_lat\": ports[origin][0],\n            \"origin_lon\": ports[origin][1],\n            \"dest_lat\": ports[dest][0],\n            \"dest_lon\": ports[dest][1],\n            \"flow\": flow,\n        }\n        for origin, dest, flow in flow_pairs\n    ]\n)\n\n# Web Mercator projection (EPSG:3857) — inlined, no helper function\nk = 6378137\ndf[\"origin_x\"] = df[\"origin_lon\"] * (k * np.pi / 180.0)\ndf[\"origin_y\"] = np.log(np.tan((90 + df[\"origin_lat\"]) * np.pi / 360.0)) * k\ndf[\"dest_x\"] = df[\"dest_lon\"] * (k * np.pi / 180.0)\ndf[\"dest_y\"] = np.log(np.tan((90 + df[\"dest_lat\"]) * np.pi / 360.0)) * k\n\n# Flow encoding: proportional line width + viridis color (continuous data)\nmin_flow = df[\"flow\"].min()\nmax_flow = df[\"flow\"].max()\ndf[\"line_width\"] = 3 + (df[\"flow\"] - min_flow) / (max_flow - min_flow) * 14\ncolor_mapper = LinearColorMapper(palette=Viridis256, low=min_flow, high=max_flow)\ncolor_idx = ((df[\"flow\"] - min_flow) / (max_flow - min_flow) * 255).astype(int).clip(0, 255)\ndf[\"line_color\"] = [Viridis256[i] for i in color_idx]\n\n# Quadratic Bezier arcs — inlined loop, no helper function\nt = np.linspace(0, 1, 50)\narc_xs, arc_ys = [], []\nfor _, row in df.iterrows():\n    x0, y0 = row[\"origin_x\"], row[\"origin_y\"]\n    x1, y1 = row[\"dest_x\"], row[\"dest_y\"]\n    mid_x = (x0 + x1) / 2\n    mid_y = (y0 + y1) / 2\n    dist = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)\n    ctrl_y = mid_y + dist * 0.2\n    arc_xs.append(((1 - t) ** 2 * x0 + 2 * (1 - t) * t * mid_x + t**2 * x1).tolist())\n    arc_ys.append(((1 - t) ** 2 * y0 + 2 * (1 - t) * t * ctrl_y + t**2 * y1).tolist())\n\narc_source = ColumnDataSource(\n    {\n        \"xs\": arc_xs,\n        \"ys\": arc_ys,\n        \"origin\": df[\"origin_name\"].tolist(),\n        \"dest\": df[\"dest_name\"].tolist(),\n        \"flow\": df[\"flow\"].tolist(),\n        \"line_width\": df[\"line_width\"].tolist(),\n        \"line_color\": df[\"line_color\"].tolist(),\n    }\n)\n\n# Port marker data — inline mercator conversion\nport_names = list(ports.keys())\nport_lats = np.array([ports[n][0] for n in port_names])\nport_lons = np.array([ports[n][1] for n in port_names])\nport_source = ColumnDataSource(\n    {\n        \"x\": port_lons * (k * np.pi / 180.0),\n        \"y\": np.log(np.tan((90 + port_lats) * np.pi / 360.0)) * k,\n        \"name\": port_names,\n        \"lat\": port_lats,\n        \"lon\": port_lons,\n    }\n)\n\n# Theme-adaptive basemap tile URL (WMTSTileSource — not deprecated string API)\ntile_url = (\n    \"https://a.basemaps.cartocdn.com/light_all/{Z}/{X}/{Y}.png\"\n    if THEME == \"light\"\n    else \"https://a.basemaps.cartocdn.com/dark_all/{Z}/{X}/{Y}.png\"\n)\n\n# Plot\np = figure(\n    width=3200,\n    height=1800,\n    title=\"flowmap-origin-destination · python · bokeh · anyplot.ai\",\n    x_axis_type=\"mercator\",\n    y_axis_type=\"mercator\",\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\np.add_tile(WMTSTileSource(url=tile_url))\n\n# Flow arcs via multi_line — single renderer enables per-arc hover tooltips\narcs = p.multi_line(\n    xs=\"xs\",\n    ys=\"ys\",\n    source=arc_source,\n    line_width=\"line_width\",\n    line_color=\"line_color\",\n    line_alpha=0.65,\n    line_cap=\"round\",\n)\n\n# Port markers (Okabe-Ito position 1 — brand green)\nports_r = p.scatter(x=\"x\", y=\"y\", source=port_source, size=20, color=\"#009E73\", alpha=0.9, legend_label=\"Ports\")\n\n# Hover tools — arcs and port markers both interactive\np.add_tools(\n    HoverTool(renderers=[arcs], tooltips=[(\"Route\", \"@origin → @dest\"), (\"Volume\", \"@flow{,} TEU\")]),\n    HoverTool(renderers=[ports_r], tooltips=[(\"Port\", \"@name\"), (\"Lat\", \"@lat{0.00}°\"), (\"Lon\", \"@lon{0.00}°\")]),\n)\n\n# Chrome — theme-adaptive colors\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\n\np.xaxis.axis_label = \"Longitude\"\np.yaxis.axis_label = \"Latitude\"\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\n\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.10\np.ygrid.grid_line_alpha = 0.10\n\np.legend.location = \"top_left\"\np.legend.label_text_font_size = \"34pt\"\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.label_text_color = INK_SOFT\n\n# ColorBar — lets viewers quantify arc colors in TEU\ncolor_bar = ColorBar(\n    color_mapper=color_mapper,\n    title=\"Volume (TEU)\",\n    title_text_font_size=\"34pt\",\n    title_text_color=INK,\n    major_label_text_font_size=\"30pt\",\n    major_label_text_color=INK_SOFT,\n    label_standoff=20,\n    width=40,\n    padding=20,\n    background_fill_color=PAGE_BG,\n    bar_line_color=INK_SOFT,\n    border_line_color=INK_SOFT,\n)\np.add_layout(color_bar, \"right\")\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Selenium (export_png uses snap chromedriver — broken)\n# W is wider than figure width=3200 to accommodate the ColorBar right panel\nW, H = 3600, 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)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}