Skip to content

Commit 5c406aa

Browse files
github-actions[bot]claudeMarkusNeusinger
authored
feat(bokeh): implement flowmap-origin-destination (#7505)
## Implementation: `flowmap-origin-destination` - python/bokeh Implements the **python/bokeh** version of `flowmap-origin-destination`. **File:** `plots/flowmap-origin-destination/implementations/python/bokeh.py` **Parent Issue:** #3765 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26154754469)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 61ac100 commit 5c406aa

3 files changed

Lines changed: 352 additions & 238 deletions

File tree

Lines changed: 185 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
flowmap-origin-destination: Origin-Destination Flow Map
3-
Library: bokeh 3.8.2 | Python 3.13.11
4-
Quality: 91/100 | Created: 2026-01-16
3+
Library: bokeh 3.9.0 | Python 3.13.13
4+
Quality: 88/100 | Updated: 2026-05-20
55
"""
66

7+
import os
8+
import sys
9+
import time
10+
from pathlib import Path
11+
12+
13+
# This file is named bokeh.py — remove its directory from sys.path so that
14+
# `import bokeh` resolves to the installed package, not this file itself.
15+
sys.path = [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
16+
717
import numpy as np
818
import pandas as pd
9-
from bokeh.io import export_png, save
10-
from bokeh.models import ColumnDataSource, HoverTool
19+
from bokeh.io import output_file, save
20+
from bokeh.models import ColorBar, ColumnDataSource, HoverTool, LinearColorMapper, WMTSTileSource
21+
from bokeh.palettes import Viridis256
1122
from bokeh.plotting import figure
12-
from bokeh.resources import CDN
23+
from selenium import webdriver
24+
from selenium.webdriver.chrome.options import Options
1325

1426

15-
# Data: Trade flow between major world ports (fictional but realistic magnitude)
27+
# Theme tokens
28+
THEME = os.getenv("ANYPLOT_THEME", "light")
29+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
30+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
31+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
32+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
33+
34+
# Data: Global maritime trade flows between major ports
1635
np.random.seed(42)
1736

18-
# Major port cities with coordinates
1937
ports = {
2038
"Shanghai": (31.2304, 121.4737),
2139
"Singapore": (1.3521, 103.8198),
@@ -29,11 +47,6 @@
2947
"Sydney": (-33.8688, 151.2093),
3048
}
3149

32-
# Generate flow data between ports
33-
flows = []
34-
port_names = list(ports.keys())
35-
36-
# Create meaningful trade flows
3750
flow_pairs = [
3851
("Shanghai", "Los Angeles", 850),
3952
("Shanghai", "Rotterdam", 720),
@@ -55,141 +68,189 @@
5568
("Dubai", "Hamburg", 260),
5669
]
5770

58-
for origin, dest, flow in flow_pairs:
59-
origin_lat, origin_lon = ports[origin]
60-
dest_lat, dest_lon = ports[dest]
61-
flows.append(
71+
df = pd.DataFrame(
72+
[
6273
{
6374
"origin_name": origin,
6475
"dest_name": dest,
65-
"origin_lat": origin_lat,
66-
"origin_lon": origin_lon,
67-
"dest_lat": dest_lat,
68-
"dest_lon": dest_lon,
76+
"origin_lat": ports[origin][0],
77+
"origin_lon": ports[origin][1],
78+
"dest_lat": ports[dest][0],
79+
"dest_lon": ports[dest][1],
6980
"flow": flow,
7081
}
71-
)
72-
73-
df = pd.DataFrame(flows)
82+
for origin, dest, flow in flow_pairs
83+
]
84+
)
7485

86+
# Web Mercator projection (EPSG:3857) — inlined, no helper function
87+
k = 6378137
88+
df["origin_x"] = df["origin_lon"] * (k * np.pi / 180.0)
89+
df["origin_y"] = np.log(np.tan((90 + df["origin_lat"]) * np.pi / 360.0)) * k
90+
df["dest_x"] = df["dest_lon"] * (k * np.pi / 180.0)
91+
df["dest_y"] = np.log(np.tan((90 + df["dest_lat"]) * np.pi / 360.0)) * k
7592

76-
# Convert lat/lon to Web Mercator projection for tile-based map
77-
def lat_lon_to_mercator(lat, lon):
78-
"""Convert latitude/longitude to Web Mercator coordinates."""
79-
k = 6378137 # Earth radius in meters
80-
x = lon * (k * np.pi / 180.0)
81-
y = np.log(np.tan((90 + lat) * np.pi / 360.0)) * k
82-
return x, y
93+
# Flow encoding: proportional line width + viridis color (continuous data)
94+
min_flow = df["flow"].min()
95+
max_flow = df["flow"].max()
96+
df["line_width"] = 3 + (df["flow"] - min_flow) / (max_flow - min_flow) * 14
97+
color_mapper = LinearColorMapper(palette=Viridis256, low=min_flow, high=max_flow)
98+
color_idx = ((df["flow"] - min_flow) / (max_flow - min_flow) * 255).astype(int).clip(0, 255)
99+
df["line_color"] = [Viridis256[i] for i in color_idx]
83100

101+
# Quadratic Bezier arcs — inlined loop, no helper function
102+
t = np.linspace(0, 1, 50)
103+
arc_xs, arc_ys = [], []
104+
for _, row in df.iterrows():
105+
x0, y0 = row["origin_x"], row["origin_y"]
106+
x1, y1 = row["dest_x"], row["dest_y"]
107+
mid_x = (x0 + x1) / 2
108+
mid_y = (y0 + y1) / 2
109+
dist = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
110+
ctrl_y = mid_y + dist * 0.2
111+
arc_xs.append(((1 - t) ** 2 * x0 + 2 * (1 - t) * t * mid_x + t**2 * x1).tolist())
112+
arc_ys.append(((1 - t) ** 2 * y0 + 2 * (1 - t) * t * ctrl_y + t**2 * y1).tolist())
84113

85-
# Convert coordinates
86-
df["origin_x"], df["origin_y"] = zip(
87-
*[lat_lon_to_mercator(lat, lon) for lat, lon in zip(df["origin_lat"], df["origin_lon"], strict=True)], strict=True
114+
arc_source = ColumnDataSource(
115+
{
116+
"xs": arc_xs,
117+
"ys": arc_ys,
118+
"origin": df["origin_name"].tolist(),
119+
"dest": df["dest_name"].tolist(),
120+
"flow": df["flow"].tolist(),
121+
"line_width": df["line_width"].tolist(),
122+
"line_color": df["line_color"].tolist(),
123+
}
88124
)
89-
df["dest_x"], df["dest_y"] = zip(
90-
*[lat_lon_to_mercator(lat, lon) for lat, lon in zip(df["dest_lat"], df["dest_lon"], strict=True)], strict=True
91-
)
92-
93125

94-
# Generate Bezier curve points for each flow
95-
def bezier_curve(x0, y0, x1, y1, num_points=50):
96-
"""Generate quadratic Bezier curve between two points with control point above midpoint."""
97-
t = np.linspace(0, 1, num_points)
98-
# Control point: midpoint with vertical offset proportional to distance
99-
mid_x = (x0 + x1) / 2
100-
mid_y = (y0 + y1) / 2
101-
distance = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
102-
# Curve height: 20% of distance, curving upward (in Mercator coordinates)
103-
ctrl_y = mid_y + distance * 0.2
104-
ctrl_x = mid_x
105-
# Quadratic Bezier
106-
bx = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * ctrl_x + t**2 * x1
107-
by = (1 - t) ** 2 * y0 + 2 * (1 - t) * t * ctrl_y + t**2 * y1
108-
return bx, by
126+
# Port marker data — inline mercator conversion
127+
port_names = list(ports.keys())
128+
port_lats = np.array([ports[n][0] for n in port_names])
129+
port_lons = np.array([ports[n][1] for n in port_names])
130+
port_source = ColumnDataSource(
131+
{
132+
"x": port_lons * (k * np.pi / 180.0),
133+
"y": np.log(np.tan((90 + port_lats) * np.pi / 360.0)) * k,
134+
"name": port_names,
135+
"lat": port_lats,
136+
"lon": port_lons,
137+
}
138+
)
109139

140+
# Theme-adaptive basemap tile URL (WMTSTileSource — not deprecated string API)
141+
tile_url = (
142+
"https://a.basemaps.cartocdn.com/light_all/{Z}/{X}/{Y}.png"
143+
if THEME == "light"
144+
else "https://a.basemaps.cartocdn.com/dark_all/{Z}/{X}/{Y}.png"
145+
)
110146

111-
# Create figure with Web Mercator projection
147+
# Plot
112148
p = figure(
113-
width=4800,
114-
height=2700,
115-
title="flowmap-origin-destination · bokeh · pyplots.ai",
149+
width=3200,
150+
height=1800,
151+
title="flowmap-origin-destination · python · bokeh · anyplot.ai",
116152
x_axis_type="mercator",
117153
y_axis_type="mercator",
118-
tools="pan,wheel_zoom,box_zoom,reset",
154+
toolbar_location=None,
155+
min_border_bottom=160,
156+
min_border_left=180,
157+
min_border_top=110,
158+
min_border_right=50,
159+
)
160+
161+
p.add_tile(WMTSTileSource(url=tile_url))
162+
163+
# Flow arcs via multi_line — single renderer enables per-arc hover tooltips
164+
arcs = p.multi_line(
165+
xs="xs",
166+
ys="ys",
167+
source=arc_source,
168+
line_width="line_width",
169+
line_color="line_color",
170+
line_alpha=0.65,
171+
line_cap="round",
172+
)
173+
174+
# Port markers (Okabe-Ito position 1 — brand green)
175+
ports_r = p.scatter(x="x", y="y", source=port_source, size=20, color="#009E73", alpha=0.9, legend_label="Ports")
176+
177+
# Hover tools — arcs and port markers both interactive
178+
p.add_tools(
179+
HoverTool(renderers=[arcs], tooltips=[("Route", "@origin → @dest"), ("Volume", "@flow{,} TEU")]),
180+
HoverTool(renderers=[ports_r], tooltips=[("Port", "@name"), ("Lat", "@lat{0.00}°"), ("Lon", "@lon{0.00}°")]),
119181
)
120182

121-
# Add tile provider for basemap (Bokeh 3.x uses string-based tile provider)
122-
p.add_tile("CartoDB Positron")
183+
# Chrome — theme-adaptive colors
184+
p.background_fill_color = PAGE_BG
185+
p.border_fill_color = PAGE_BG
186+
p.outline_line_color = INK_SOFT
187+
188+
p.title.text_font_size = "50pt"
189+
p.title.text_color = INK
123190

124-
# Style the title and axes
125-
p.title.text_font_size = "28pt"
126191
p.xaxis.axis_label = "Longitude"
127192
p.yaxis.axis_label = "Latitude"
128-
p.xaxis.axis_label_text_font_size = "22pt"
129-
p.yaxis.axis_label_text_font_size = "22pt"
130-
p.xaxis.major_label_text_font_size = "16pt"
131-
p.yaxis.major_label_text_font_size = "16pt"
193+
p.xaxis.axis_label_text_font_size = "42pt"
194+
p.yaxis.axis_label_text_font_size = "42pt"
195+
p.xaxis.major_label_text_font_size = "34pt"
196+
p.yaxis.major_label_text_font_size = "34pt"
197+
p.xaxis.axis_label_text_color = INK
198+
p.yaxis.axis_label_text_color = INK
199+
p.xaxis.major_label_text_color = INK_SOFT
200+
p.yaxis.major_label_text_color = INK_SOFT
201+
p.xaxis.axis_line_color = INK_SOFT
202+
p.yaxis.axis_line_color = INK_SOFT
203+
p.xaxis.major_tick_line_color = INK_SOFT
204+
p.yaxis.major_tick_line_color = INK_SOFT
132205

133-
# Normalize flow for line width (scale to 2-12 range)
134-
min_flow = df["flow"].min()
135-
max_flow = df["flow"].max()
136-
df["line_width"] = 2 + (df["flow"] - min_flow) / (max_flow - min_flow) * 10
206+
p.xgrid.grid_line_color = INK
207+
p.ygrid.grid_line_color = INK
208+
p.xgrid.grid_line_alpha = 0.10
209+
p.ygrid.grid_line_alpha = 0.10
137210

138-
# Color scale based on flow magnitude (Python Blue to Yellow gradient)
139-
df["color"] = df["flow"].apply(
140-
lambda f: f"#{int(48 + (255 - 48) * (f - min_flow) / (max_flow - min_flow)):02x}"
141-
f"{int(105 + (212 - 105) * (f - min_flow) / (max_flow - min_flow)):02x}"
142-
f"{int(152 + (59 - 152) * (f - min_flow) / (max_flow - min_flow)):02x}"
143-
)
211+
p.legend.location = "top_left"
212+
p.legend.label_text_font_size = "34pt"
213+
p.legend.background_fill_color = ELEVATED_BG
214+
p.legend.border_line_color = INK_SOFT
215+
p.legend.label_text_color = INK_SOFT
144216

145-
# Draw curved arcs for each flow
146-
for _, row in df.iterrows():
147-
curve_x, curve_y = bezier_curve(row["origin_x"], row["origin_y"], row["dest_x"], row["dest_y"])
148-
149-
# Create source for this arc with hover data
150-
arc_source = ColumnDataSource(
151-
data={
152-
"x": curve_x,
153-
"y": curve_y,
154-
"origin": [row["origin_name"]] * len(curve_x),
155-
"dest": [row["dest_name"]] * len(curve_x),
156-
"flow": [row["flow"]] * len(curve_x),
157-
}
158-
)
159-
160-
p.line(
161-
x="x",
162-
y="y",
163-
source=arc_source,
164-
line_width=row["line_width"],
165-
line_color=row["color"],
166-
line_alpha=0.6,
167-
line_cap="round",
168-
)
169-
170-
# Add origin/destination points
171-
port_data = []
172-
for name, (lat, lon) in ports.items():
173-
x, y = lat_lon_to_mercator(lat, lon)
174-
port_data.append({"name": name, "x": x, "y": y, "lat": lat, "lon": lon})
175-
176-
port_df = pd.DataFrame(port_data)
177-
port_source = ColumnDataSource(port_df)
178-
179-
# Draw port markers
180-
p.scatter(x="x", y="y", source=port_source, size=20, color="#306998", alpha=0.9, legend_label="Ports")
181-
182-
# Add hover tool for ports
183-
hover_ports = HoverTool(
184-
tooltips=[("Port", "@name"), ("Latitude", "@lat{0.00}"), ("Longitude", "@lon{0.00}")], renderers=[p.renderers[-1]]
217+
# ColorBar — lets viewers quantify arc colors in TEU
218+
color_bar = ColorBar(
219+
color_mapper=color_mapper,
220+
title="Volume (TEU)",
221+
title_text_font_size="34pt",
222+
title_text_color=INK,
223+
major_label_text_font_size="30pt",
224+
major_label_text_color=INK_SOFT,
225+
label_standoff=20,
226+
width=40,
227+
padding=20,
228+
background_fill_color=PAGE_BG,
229+
bar_line_color=INK_SOFT,
230+
border_line_color=INK_SOFT,
185231
)
186-
p.add_tools(hover_ports)
232+
p.add_layout(color_bar, "right")
187233

188-
# Legend styling
189-
p.legend.location = "top_left"
190-
p.legend.label_text_font_size = "18pt"
191-
p.legend.background_fill_alpha = 0.8
234+
# Save HTML
235+
output_file(f"plot-{THEME}.html")
236+
save(p)
192237

193-
# Save outputs
194-
export_png(p, filename="plot.png")
195-
save(p, filename="plot.html", title="Origin-Destination Flow Map", resources=CDN)
238+
# Screenshot with headless Selenium (export_png uses snap chromedriver — broken)
239+
# W is wider than figure width=3200 to accommodate the ColorBar right panel
240+
W, H = 3600, 1800
241+
opts = Options()
242+
for arg in (
243+
"--headless=new",
244+
"--no-sandbox",
245+
"--disable-dev-shm-usage",
246+
"--disable-gpu",
247+
f"--window-size={W},{H}",
248+
"--hide-scrollbars",
249+
):
250+
opts.add_argument(arg)
251+
driver = webdriver.Chrome(options=opts)
252+
driver.set_window_size(W, H)
253+
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
254+
time.sleep(3)
255+
driver.save_screenshot(f"plot-{THEME}.png")
256+
driver.quit()

0 commit comments

Comments
 (0)