Skip to content

Commit dd61ceb

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

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
""" pyplots.ai
2+
circos-basic: Circos Plot
3+
Library: plotly 6.5.0 | Python 3.13.11
4+
Quality: 90/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
import plotly.graph_objects as go
9+
10+
11+
# Data: Trade flows between regions (as example for circos visualization)
12+
np.random.seed(42)
13+
14+
# Define 8 segments (regions) for the circular layout
15+
segments = ["North America", "Europe", "East Asia", "South America", "Africa", "Middle East", "South Asia", "Oceania"]
16+
n_segments = len(segments)
17+
18+
# Segment sizes (proportional to economic importance)
19+
segment_sizes = np.array([25, 30, 28, 10, 8, 12, 15, 6])
20+
segment_sizes = segment_sizes / segment_sizes.sum() * 360 # Normalize to 360 degrees
21+
22+
# Connection matrix (trade flow values)
23+
# Random but symmetric-ish values for bilateral trade
24+
connections = np.array(
25+
[
26+
[0, 45, 60, 15, 5, 10, 8, 12], # North America
27+
[40, 0, 35, 12, 18, 25, 15, 8], # Europe
28+
[55, 38, 0, 10, 12, 20, 30, 18], # East Asia
29+
[12, 10, 8, 0, 8, 3, 4, 5], # South America
30+
[6, 20, 10, 10, 0, 15, 6, 2], # Africa
31+
[12, 28, 22, 4, 12, 0, 18, 5], # Middle East
32+
[10, 18, 35, 5, 8, 22, 0, 8], # South Asia
33+
[15, 10, 22, 6, 3, 6, 10, 0], # Oceania
34+
]
35+
)
36+
37+
# Colors for each segment
38+
colors = ["#306998", "#FFD43B", "#E34234", "#2ECC71", "#9B59B6", "#E67E22", "#1ABC9C", "#3498DB"]
39+
40+
41+
# Helper to blend two hex colors
42+
def blend_colors(c1, c2, ratio=0.5):
43+
"""Blend two hex colors. ratio=0 gives c1, ratio=1 gives c2."""
44+
r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)
45+
r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16)
46+
r = int(r1 * (1 - ratio) + r2 * ratio)
47+
g = int(g1 * (1 - ratio) + g2 * ratio)
48+
b = int(b1 * (1 - ratio) + b2 * ratio)
49+
return f"#{r:02x}{g:02x}{b:02x}"
50+
51+
52+
# Calculate segment positions on the circle
53+
gap = 2 # Gap between segments in degrees
54+
total_gap = gap * n_segments
55+
available = 360 - total_gap
56+
segment_angles = segment_sizes / segment_sizes.sum() * available
57+
58+
# Starting angles for each segment
59+
start_angles = np.zeros(n_segments)
60+
for i in range(1, n_segments):
61+
start_angles[i] = start_angles[i - 1] + segment_angles[i - 1] + gap
62+
63+
# Create figure
64+
fig = go.Figure()
65+
66+
# Outer ring radius
67+
outer_r = 1.0
68+
inner_r = 0.85
69+
ribbon_inner = 0.80
70+
71+
# Draw outer segments (arcs)
72+
for i in range(n_segments):
73+
theta_start = start_angles[i]
74+
theta_end = theta_start + segment_angles[i]
75+
76+
# Create arc points
77+
theta = np.linspace(np.radians(theta_start), np.radians(theta_end), 50)
78+
theta_rev = theta[::-1]
79+
80+
# Outer arc
81+
x_outer = outer_r * np.cos(theta)
82+
y_outer = outer_r * np.sin(theta)
83+
84+
# Inner arc (for the segment)
85+
x_inner = inner_r * np.cos(theta_rev)
86+
y_inner = inner_r * np.sin(theta_rev)
87+
88+
# Combine to make a filled arc
89+
x_arc = np.concatenate([x_outer, x_inner, [x_outer[0]]])
90+
y_arc = np.concatenate([y_outer, y_inner, [y_outer[0]]])
91+
92+
fig.add_trace(
93+
go.Scatter(
94+
x=x_arc,
95+
y=y_arc,
96+
fill="toself",
97+
fillcolor=colors[i],
98+
line=dict(color="white", width=1),
99+
name=segments[i],
100+
hoverinfo="name",
101+
showlegend=True,
102+
)
103+
)
104+
105+
# Add label for segment
106+
mid_angle = np.radians((theta_start + theta_end) / 2)
107+
label_r = outer_r + 0.12
108+
label_x = label_r * np.cos(mid_angle)
109+
label_y = label_r * np.sin(mid_angle)
110+
111+
# Rotate text based on position for better readability
112+
text_angle = (theta_start + theta_end) / 2
113+
if 90 < text_angle < 270:
114+
text_angle = text_angle - 180
115+
116+
# Adjust text anchor based on position for less cramping
117+
mid_deg = (theta_start + theta_end) / 2
118+
if 45 < mid_deg < 135:
119+
xanchor = "center"
120+
yanchor = "bottom"
121+
elif 225 < mid_deg < 315:
122+
xanchor = "center"
123+
yanchor = "top"
124+
elif mid_deg <= 45 or mid_deg >= 315:
125+
xanchor = "left"
126+
yanchor = "middle"
127+
else:
128+
xanchor = "right"
129+
yanchor = "middle"
130+
131+
fig.add_annotation(
132+
x=label_x,
133+
y=label_y,
134+
text=segments[i],
135+
showarrow=False,
136+
font=dict(size=16, color="#333333"),
137+
textangle=-text_angle,
138+
xanchor=xanchor,
139+
yanchor=yanchor,
140+
)
141+
142+
# Draw ribbons (connections between segments)
143+
# Get midpoint angles for each segment
144+
mid_angles = start_angles + segment_angles / 2
145+
146+
# Track positions within each segment for ribbon placement
147+
segment_positions = np.zeros(n_segments)
148+
149+
# Draw connections as curved ribbons
150+
for i in range(n_segments):
151+
for j in range(i + 1, n_segments):
152+
if connections[i, j] > 5: # Only show significant connections
153+
# Normalize ribbon width
154+
max_conn = connections.max()
155+
width_i = (connections[i, j] / max_conn) * segment_angles[i] * 0.3
156+
width_j = (connections[i, j] / max_conn) * segment_angles[j] * 0.3
157+
158+
# Source positions
159+
theta_i_start = start_angles[i] + segment_positions[i]
160+
theta_i_end = theta_i_start + width_i
161+
segment_positions[i] += width_i + 1
162+
163+
# Target positions
164+
theta_j_start = start_angles[j] + segment_positions[j]
165+
theta_j_end = theta_j_start + width_j
166+
segment_positions[j] += width_j + 1
167+
168+
# Create bezier-like ribbon using multiple points
169+
n_points = 30
170+
171+
# Source arc points
172+
theta_src = np.linspace(np.radians(theta_i_start), np.radians(theta_i_end), 10)
173+
x_src = ribbon_inner * np.cos(theta_src)
174+
y_src = ribbon_inner * np.sin(theta_src)
175+
176+
# Target arc points
177+
theta_tgt = np.linspace(np.radians(theta_j_start), np.radians(theta_j_end), 10)
178+
x_tgt = ribbon_inner * np.cos(theta_tgt)
179+
y_tgt = ribbon_inner * np.sin(theta_tgt)
180+
181+
# Create curved path through center
182+
# Bezier-like curve from source to target
183+
t = np.linspace(0, 1, n_points)
184+
185+
# Control points - curve through center with some offset
186+
cp1_x, cp1_y = 0.2 * x_src[-1], 0.2 * y_src[-1]
187+
cp2_x, cp2_y = 0.2 * x_tgt[0], 0.2 * y_tgt[0]
188+
189+
# Quadratic bezier for top edge
190+
curve1_x = (1 - t) ** 2 * x_src[-1] + 2 * (1 - t) * t * cp1_x + t**2 * x_tgt[0]
191+
curve1_y = (1 - t) ** 2 * y_src[-1] + 2 * (1 - t) * t * cp1_y + t**2 * y_tgt[0]
192+
193+
# Control points for bottom edge
194+
cp3_x, cp3_y = 0.2 * x_tgt[-1], 0.2 * y_tgt[-1]
195+
cp4_x, cp4_y = 0.2 * x_src[0], 0.2 * y_src[0]
196+
197+
# Quadratic bezier for bottom edge (reversed)
198+
curve2_x = (1 - t) ** 2 * x_tgt[-1] + 2 * (1 - t) * t * cp3_x + t**2 * x_src[0]
199+
curve2_y = (1 - t) ** 2 * y_tgt[-1] + 2 * (1 - t) * t * cp3_y + t**2 * y_src[0]
200+
201+
# Combine all points to form ribbon shape
202+
x_ribbon = np.concatenate([x_src, curve1_x, x_tgt, curve2_x, [x_src[0]]])
203+
y_ribbon = np.concatenate([y_src, curve1_y, y_tgt, curve2_y, [y_src[0]]])
204+
205+
# Blend colors from source and target segments for better visual connection
206+
ribbon_color = blend_colors(colors[i], colors[j], 0.5)
207+
fig.add_trace(
208+
go.Scatter(
209+
x=x_ribbon,
210+
y=y_ribbon,
211+
fill="toself",
212+
fillcolor=ribbon_color,
213+
opacity=0.5,
214+
line=dict(color="white", width=0.5),
215+
hoverinfo="text",
216+
hovertext=f"{segments[i]}{segments[j]}: {connections[i, j]}",
217+
showlegend=False,
218+
)
219+
)
220+
221+
# Add inner track (simulated data - e.g., GDP values as bar heights)
222+
track_r_outer = 0.78
223+
track_r_inner = 0.60
224+
track_values = np.array([0.8, 0.95, 0.9, 0.4, 0.25, 0.5, 0.55, 0.3])
225+
226+
for i in range(n_segments):
227+
theta_start = start_angles[i]
228+
theta_end = theta_start + segment_angles[i]
229+
230+
theta = np.linspace(np.radians(theta_start), np.radians(theta_end), 30)
231+
theta_rev = theta[::-1]
232+
233+
# Height based on track value
234+
height = track_r_inner + (track_r_outer - track_r_inner) * track_values[i]
235+
236+
x_outer = height * np.cos(theta)
237+
y_outer = height * np.sin(theta)
238+
x_inner = track_r_inner * np.cos(theta_rev)
239+
y_inner = track_r_inner * np.sin(theta_rev)
240+
241+
x_bar = np.concatenate([x_outer, x_inner, [x_outer[0]]])
242+
y_bar = np.concatenate([y_outer, y_inner, [y_outer[0]]])
243+
244+
fig.add_trace(
245+
go.Scatter(
246+
x=x_bar,
247+
y=y_bar,
248+
fill="toself",
249+
fillcolor=colors[i],
250+
opacity=0.6,
251+
line=dict(color="white", width=0.5),
252+
hoverinfo="text",
253+
hovertext=f"{segments[i]} GDP Index: {track_values[i]:.2f}",
254+
showlegend=False,
255+
)
256+
)
257+
258+
# Update layout
259+
fig.update_layout(
260+
title=dict(text="circos-basic · plotly · pyplots.ai", font=dict(size=28, color="#333333"), x=0.5, xanchor="center"),
261+
showlegend=True,
262+
legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5, font=dict(size=14)),
263+
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-1.5, 1.5], scaleanchor="y", scaleratio=1),
264+
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-1.5, 1.5]),
265+
plot_bgcolor="white",
266+
paper_bgcolor="white",
267+
margin=dict(l=50, r=50, t=100, b=120),
268+
)
269+
270+
# Save as PNG (4800x2700 equivalent via scale)
271+
fig.write_image("plot.png", width=1600, height=900, scale=3)
272+
273+
# Save interactive HTML version
274+
fig.write_html("plot.html", include_plotlyjs=True, full_html=True)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
library: plotly
2+
specification_id: circos-basic
3+
created: '2025-12-31T11:06:47Z'
4+
updated: '2025-12-31T11:28:17Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617614742
7+
issue: 3005
8+
python_version: 3.13.11
9+
library_version: 6.5.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/circos-basic/plotly/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/circos-basic/plotly/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/circos-basic/plotly/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent implementation of Circos visualization using Plotly Scatter with fill=toself
17+
for complex shapes
18+
- Ribbon colors now properly blend source and target segment colors (addressed previous
19+
feedback)
20+
- Includes inner track for additional data layer (GDP index) as specified
21+
- Good interactive HTML output alongside PNG export
22+
- Well-chosen color palette with 8 distinct, accessible colors
23+
- Proper proportional sizing of segments based on economic importance
24+
- Adaptive label positioning based on angle to minimize overlap
25+
weaknesses:
26+
- 'Title format still includes extra descriptor: should be circos-basic · plotly
27+
· pyplots.ai not Regional Trade Flows · circos-basic · plotly · pyplots.ai'
28+
- Helper function blend_colors() violates strict KISS principle (imports → data
29+
→ plot → save)

0 commit comments

Comments
 (0)