Skip to content

Commit a9b1587

Browse files
feat(bokeh): implement circos-basic (#3062)
## Implementation: `circos-basic` - bokeh Implements the **bokeh** version of `circos-basic`. **File:** `plots/circos-basic/implementations/bokeh.py` **Parent Issue:** #3005 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20617695887)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent ab51db1 commit a9b1587

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
""" pyplots.ai
2+
circos-basic: Circos Plot
3+
Library: bokeh 3.8.1 | Python 3.13.11
4+
Quality: 90/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
from bokeh.io import export_png, output_file, save
9+
from bokeh.models import ColumnDataSource
10+
from bokeh.plotting import figure
11+
12+
13+
np.random.seed(42)
14+
15+
# Data: Regional trade flows between economic regions
16+
regions = ["Asia", "Europe", "N. America", "S. America", "Africa", "Oceania"]
17+
n_regions = len(regions)
18+
19+
# Connection matrix (trade flows between regions in billions USD)
20+
# Row = source, Column = target
21+
flow_matrix = np.array(
22+
[
23+
[0, 45, 52, 18, 15, 22], # Asia exports to...
24+
[38, 0, 35, 12, 20, 8], # Europe exports to...
25+
[48, 42, 0, 28, 10, 15], # N. America exports to...
26+
[15, 18, 25, 0, 8, 5], # S. America exports to...
27+
[12, 25, 8, 10, 0, 3], # Africa exports to...
28+
[20, 10, 18, 6, 4, 0], # Oceania exports to...
29+
]
30+
)
31+
32+
# Segment sizes (total trade volume for each region)
33+
segment_sizes = flow_matrix.sum(axis=0) + flow_matrix.sum(axis=1)
34+
35+
# Track data (GDP growth rate for each region)
36+
track_values = np.array([4.2, 1.8, 2.5, 1.5, 3.8, 2.2])
37+
38+
# Color palette for regions
39+
colors = ["#306998", "#FFD43B", "#E85C47", "#4DAF4A", "#984EA3", "#FF7F00"]
40+
41+
# Calculate segment positions (angles)
42+
total_size = segment_sizes.sum()
43+
gap = 0.03 # Gap between segments (radians)
44+
total_gap = gap * n_regions
45+
available_angle = 2 * np.pi - total_gap
46+
47+
segment_angles = []
48+
current_angle = 0
49+
for size in segment_sizes:
50+
angle_span = (size / total_size) * available_angle
51+
start = current_angle
52+
end = current_angle + angle_span
53+
segment_angles.append((start, end))
54+
current_angle = end + gap
55+
56+
# Create figure (square for circular plot)
57+
p = figure(
58+
width=3600,
59+
height=3600,
60+
title="circos-basic · bokeh · pyplots.ai",
61+
x_range=(-1.5, 1.5),
62+
y_range=(-1.5, 1.5),
63+
tools="",
64+
toolbar_location=None,
65+
)
66+
67+
# Styling
68+
p.title.text_font_size = "48pt"
69+
p.title.align = "center"
70+
p.xaxis.visible = False
71+
p.yaxis.visible = False
72+
p.xgrid.visible = False
73+
p.ygrid.visible = False
74+
p.outline_line_color = None
75+
p.background_fill_color = "white"
76+
77+
outer_radius = 1.0
78+
inner_radius = 0.85
79+
track_outer = 0.82
80+
track_inner = 0.70
81+
ribbon_radius = 0.65
82+
83+
84+
# Draw outer segments (arcs)
85+
for i, (start, end) in enumerate(segment_angles):
86+
# Outer arc
87+
theta = np.linspace(start, end, 50)
88+
outer_x = outer_radius * np.cos(theta)
89+
outer_y = outer_radius * np.sin(theta)
90+
inner_x = inner_radius * np.cos(theta[::-1])
91+
inner_y = inner_radius * np.sin(theta[::-1])
92+
93+
xs = np.concatenate([outer_x, inner_x, [outer_x[0]]])
94+
ys = np.concatenate([outer_y, inner_y, [outer_y[0]]])
95+
96+
source = ColumnDataSource(data={"xs": [xs], "ys": [ys]})
97+
p.patches(xs="xs", ys="ys", source=source, fill_color=colors[i], line_color="white", line_width=2, alpha=0.9)
98+
99+
# Add region label
100+
mid_angle = (start + end) / 2
101+
label_radius = outer_radius + 0.12
102+
label_x = label_radius * np.cos(mid_angle)
103+
label_y = label_radius * np.sin(mid_angle)
104+
105+
# Rotate text based on position
106+
angle = mid_angle * 180 / np.pi
107+
if 90 < angle < 270:
108+
angle += 180
109+
110+
p.text(
111+
x=[label_x],
112+
y=[label_y],
113+
text=[regions[i]],
114+
text_font_size="28pt",
115+
text_align="center",
116+
text_baseline="middle",
117+
text_color="#333333",
118+
angle=[np.radians(angle - 90)],
119+
)
120+
121+
# Draw inner track (GDP growth rate)
122+
max_track = track_values.max()
123+
min_track = track_values.min()
124+
track_range = max_track - min_track
125+
126+
for i, (start, end) in enumerate(segment_angles):
127+
# Normalized track value
128+
norm_val = (track_values[i] - min_track) / track_range if track_range > 0 else 0.5
129+
bar_radius = track_inner + norm_val * (track_outer - track_inner)
130+
131+
theta = np.linspace(start, end, 30)
132+
outer_x = bar_radius * np.cos(theta)
133+
outer_y = bar_radius * np.sin(theta)
134+
inner_x = track_inner * np.cos(theta[::-1])
135+
inner_y = track_inner * np.sin(theta[::-1])
136+
137+
xs = np.concatenate([outer_x, inner_x, [outer_x[0]]])
138+
ys = np.concatenate([outer_y, inner_y, [outer_y[0]]])
139+
140+
source = ColumnDataSource(data={"xs": [xs], "ys": [ys]})
141+
p.patches(xs="xs", ys="ys", source=source, fill_color=colors[i], line_color=None, alpha=0.6)
142+
143+
# Draw track reference circle
144+
track_ref_theta = np.linspace(0, 2 * np.pi, 100)
145+
track_ref_x = track_inner * np.cos(track_ref_theta)
146+
track_ref_y = track_inner * np.sin(track_ref_theta)
147+
p.line(track_ref_x, track_ref_y, line_color="#cccccc", line_width=1, line_alpha=0.5)
148+
149+
# Draw ribbons (connections between regions)
150+
# Filter significant flows
151+
flow_threshold = 15
152+
153+
for i in range(n_regions):
154+
for j in range(i + 1, n_regions): # Only upper triangle to avoid duplicates
155+
flow_ij = flow_matrix[i, j]
156+
flow_ji = flow_matrix[j, i]
157+
total_flow = flow_ij + flow_ji
158+
159+
if total_flow < flow_threshold:
160+
continue
161+
162+
# Calculate ribbon widths proportional to flow
163+
# Source segment i
164+
start_i, end_i = segment_angles[i]
165+
seg_span_i = end_i - start_i
166+
ribbon_width_i = (total_flow / segment_sizes[i]) * seg_span_i * 0.8
167+
168+
# Target segment j
169+
start_j, end_j = segment_angles[j]
170+
seg_span_j = end_j - start_j
171+
ribbon_width_j = (total_flow / segment_sizes[j]) * seg_span_j * 0.8
172+
173+
# Position ribbons at center of segments
174+
mid_i = (start_i + end_i) / 2
175+
mid_j = (start_j + end_j) / 2
176+
177+
# Ribbon endpoints on source
178+
theta_i_start = mid_i - ribbon_width_i / 2
179+
theta_i_end = mid_i + ribbon_width_i / 2
180+
181+
# Ribbon endpoints on target
182+
theta_j_start = mid_j - ribbon_width_j / 2
183+
theta_j_end = mid_j + ribbon_width_j / 2
184+
185+
# Create bezier-like ribbon using quadratic curves
186+
n_curve = 30
187+
188+
# Path: from i_start arc to j_start, then j arc, then back via bezier
189+
# Side 1: from i_start to j_start
190+
t = np.linspace(0, 1, n_curve)
191+
# Control point at center
192+
ctrl_x, ctrl_y = 0, 0
193+
194+
# Start point
195+
x1_start = ribbon_radius * np.cos(theta_i_start)
196+
y1_start = ribbon_radius * np.sin(theta_i_start)
197+
# End point
198+
x1_end = ribbon_radius * np.cos(theta_j_start)
199+
y1_end = ribbon_radius * np.sin(theta_j_start)
200+
201+
# Quadratic bezier
202+
curve1_x = (1 - t) ** 2 * x1_start + 2 * (1 - t) * t * ctrl_x + t**2 * x1_end
203+
curve1_y = (1 - t) ** 2 * y1_start + 2 * (1 - t) * t * ctrl_y + t**2 * y1_end
204+
205+
# Arc at j
206+
arc_j_theta = np.linspace(theta_j_start, theta_j_end, 10)
207+
arc_j_x = ribbon_radius * np.cos(arc_j_theta)
208+
arc_j_y = ribbon_radius * np.sin(arc_j_theta)
209+
210+
# Side 2: from j_end back to i_end
211+
x2_start = ribbon_radius * np.cos(theta_j_end)
212+
y2_start = ribbon_radius * np.sin(theta_j_end)
213+
x2_end = ribbon_radius * np.cos(theta_i_end)
214+
y2_end = ribbon_radius * np.sin(theta_i_end)
215+
216+
curve2_x = (1 - t) ** 2 * x2_start + 2 * (1 - t) * t * ctrl_x + t**2 * x2_end
217+
curve2_y = (1 - t) ** 2 * y2_start + 2 * (1 - t) * t * ctrl_y + t**2 * y2_end
218+
219+
# Arc at i
220+
arc_i_theta = np.linspace(theta_i_end, theta_i_start, 10)
221+
arc_i_x = ribbon_radius * np.cos(arc_i_theta)
222+
arc_i_y = ribbon_radius * np.sin(arc_i_theta)
223+
224+
# Combine all points
225+
ribbon_x = np.concatenate([curve1_x, arc_j_x, curve2_x, arc_i_x])
226+
ribbon_y = np.concatenate([curve1_y, arc_j_y, curve2_y, arc_i_y])
227+
228+
# Use gradient color (blend of source and target)
229+
ribbon_color = colors[i]
230+
231+
source = ColumnDataSource(data={"xs": [ribbon_x], "ys": [ribbon_y]})
232+
p.patches(
233+
xs="xs", ys="ys", source=source, fill_color=ribbon_color, line_color=ribbon_color, line_width=0.5, alpha=0.5
234+
)
235+
236+
# Add legend manually
237+
legend_x = 1.15
238+
legend_y_start = 0.8
239+
legend_spacing = 0.15
240+
241+
for i, region in enumerate(regions):
242+
y_pos = legend_y_start - i * legend_spacing
243+
# Color box
244+
p.rect(x=[legend_x], y=[y_pos], width=0.08, height=0.08, fill_color=colors[i], line_color=None)
245+
# Label
246+
p.text(
247+
x=[legend_x + 0.08],
248+
y=[y_pos],
249+
text=[region],
250+
text_font_size="16pt",
251+
text_align="left",
252+
text_baseline="middle",
253+
text_color="#333333",
254+
)
255+
256+
# Add title for track (positioned near the inner track for clarity)
257+
p.text(x=[-0.45], y=[-0.45], text=["Inner track:"], text_font_size="20pt", text_color="#666666", text_align="center")
258+
p.text(x=[-0.45], y=[-0.55], text=["GDP Growth (%)"], text_font_size="20pt", text_color="#666666", text_align="center")
259+
260+
# Save outputs
261+
export_png(p, filename="plot.png")
262+
263+
# Save HTML for interactivity
264+
output_file("plot.html")
265+
save(p)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
library: bokeh
2+
specification_id: circos-basic
3+
created: '2025-12-31T11:12:52Z'
4+
updated: '2025-12-31T11:28:09Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617695887
7+
issue: 3005
8+
python_version: 3.13.11
9+
library_version: 3.8.1
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/circos-basic/bokeh/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/circos-basic/bokeh/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/circos-basic/bokeh/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent circular layout with proper segment sizing proportional to total trade
17+
volume
18+
- Well-implemented bezier ribbons connecting regions with appropriate transparency
19+
- Good use of inner track to display additional GDP growth data
20+
- Realistic trade flow scenario with sensible numeric values
21+
- Clean legend placement and region labeling with proper text rotation
22+
- Both PNG and HTML outputs generated for static and interactive viewing
23+
weaknesses:
24+
- Inner track annotation positioned at bottom-left is quite small and easy to miss
25+
- Title font at 48pt appears relatively small for the 3600x3600 canvas size
26+
- Some ribbon colors could use slightly more differentiation when many overlap in
27+
the center

0 commit comments

Comments
 (0)