|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 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 | + |
7 | 17 | import numpy as np |
8 | 18 | 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 |
11 | 22 | from bokeh.plotting import figure |
12 | | -from bokeh.resources import CDN |
| 23 | +from selenium import webdriver |
| 24 | +from selenium.webdriver.chrome.options import Options |
13 | 25 |
|
14 | 26 |
|
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 |
16 | 35 | np.random.seed(42) |
17 | 36 |
|
18 | | -# Major port cities with coordinates |
19 | 37 | ports = { |
20 | 38 | "Shanghai": (31.2304, 121.4737), |
21 | 39 | "Singapore": (1.3521, 103.8198), |
|
29 | 47 | "Sydney": (-33.8688, 151.2093), |
30 | 48 | } |
31 | 49 |
|
32 | | -# Generate flow data between ports |
33 | | -flows = [] |
34 | | -port_names = list(ports.keys()) |
35 | | - |
36 | | -# Create meaningful trade flows |
37 | 50 | flow_pairs = [ |
38 | 51 | ("Shanghai", "Los Angeles", 850), |
39 | 52 | ("Shanghai", "Rotterdam", 720), |
|
55 | 68 | ("Dubai", "Hamburg", 260), |
56 | 69 | ] |
57 | 70 |
|
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 | + [ |
62 | 73 | { |
63 | 74 | "origin_name": origin, |
64 | 75 | "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], |
69 | 80 | "flow": flow, |
70 | 81 | } |
71 | | - ) |
72 | | - |
73 | | -df = pd.DataFrame(flows) |
| 82 | + for origin, dest, flow in flow_pairs |
| 83 | + ] |
| 84 | +) |
74 | 85 |
|
| 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 |
75 | 92 |
|
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] |
83 | 100 |
|
| 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()) |
84 | 113 |
|
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 | + } |
88 | 124 | ) |
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 | | - |
93 | 125 |
|
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 | +) |
109 | 139 |
|
| 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 | +) |
110 | 146 |
|
111 | | -# Create figure with Web Mercator projection |
| 147 | +# Plot |
112 | 148 | 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", |
116 | 152 | x_axis_type="mercator", |
117 | 153 | 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}°")]), |
119 | 181 | ) |
120 | 182 |
|
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 |
123 | 190 |
|
124 | | -# Style the title and axes |
125 | | -p.title.text_font_size = "28pt" |
126 | 191 | p.xaxis.axis_label = "Longitude" |
127 | 192 | 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 |
132 | 205 |
|
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 |
137 | 210 |
|
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 |
144 | 216 |
|
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, |
185 | 231 | ) |
186 | | -p.add_tools(hover_ports) |
| 232 | +p.add_layout(color_bar, "right") |
187 | 233 |
|
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) |
192 | 237 |
|
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