Skip to content

Commit 5568d9f

Browse files
feat(seaborn): implement circos-basic (#3056)
## Implementation: `circos-basic` - seaborn Implements the **seaborn** version of `circos-basic`. **File:** `plots/circos-basic/implementations/seaborn.py` **Parent Issue:** #3005 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20617630017)* --------- 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 c0f38e7 commit 5568d9f

2 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""pyplots.ai
2+
circos-basic: Circos Plot
3+
Library: seaborn 0.13.2 | Python 3.13.11
4+
Quality: 82/100 | Created: 2025-12-31
5+
"""
6+
7+
import matplotlib.patches as mpatches
8+
import matplotlib.pyplot as plt
9+
import numpy as np
10+
import seaborn as sns
11+
12+
13+
# Set seaborn theme for consistent styling with larger fonts
14+
sns.set_theme(style="white", context="poster", font_scale=1.3)
15+
16+
# Data: Regional trade flows (10 regions with trade connections)
17+
np.random.seed(42)
18+
19+
# Define segments (regions) with their sizes (trade volume in billion USD)
20+
# Reordered to ensure adjacent regions have distinct colors
21+
segments = [
22+
"North America",
23+
"East Asia",
24+
"Europe",
25+
"South Asia",
26+
"Middle East",
27+
"Southeast Asia",
28+
"Africa",
29+
"Oceania",
30+
"South America",
31+
"Central Asia",
32+
]
33+
n_segments = len(segments)
34+
35+
# Segment sizes represent total trade volume (billion USD) - reordered
36+
segment_sizes = np.array([250, 280, 320, 120, 100, 150, 80, 60, 90, 50])
37+
38+
# Create connection data (source, target, value in billion USD)
39+
# Updated indices for reordered segments:
40+
# 0=North America, 1=East Asia, 2=Europe, 3=South Asia, 4=Middle East,
41+
# 5=Southeast Asia, 6=Africa, 7=Oceania, 8=South America, 9=Central Asia
42+
connections = [
43+
(0, 2, 85), # North America - Europe
44+
(0, 1, 120), # North America - East Asia
45+
(2, 1, 95), # Europe - East Asia
46+
(2, 4, 60), # Europe - Middle East
47+
(1, 5, 70), # East Asia - Southeast Asia
48+
(1, 3, 45), # East Asia - South Asia
49+
(5, 3, 35), # Southeast Asia - South Asia
50+
(2, 6, 40), # Europe - Africa
51+
(0, 8, 55), # North America - South America
52+
(1, 7, 50), # East Asia - Oceania
53+
(4, 3, 30), # Middle East - South Asia
54+
(4, 6, 25), # Middle East - Africa
55+
(2, 9, 20), # Europe - Central Asia
56+
(1, 9, 28), # East Asia - Central Asia
57+
(0, 5, 38), # North America - Southeast Asia
58+
]
59+
60+
# Use seaborn's diverging color palette for better distinction between adjacent segments
61+
# tab10 provides 10 distinct colors that work well for categorical data
62+
colors = sns.color_palette("tab10", n_colors=n_segments)
63+
64+
# Create square figure for circular symmetry (3600x3600 at 300 dpi = 12x12 inches)
65+
fig, ax = plt.subplots(figsize=(12, 12))
66+
ax.set_aspect("equal")
67+
68+
# Calculate segment positions (angles)
69+
total_size = segment_sizes.sum()
70+
gap_fraction = 0.02
71+
total_gap = gap_fraction * n_segments
72+
available_angle = 2 * np.pi * (1 - total_gap / (2 * np.pi))
73+
74+
angles = []
75+
current_angle = np.pi / 2
76+
77+
for size in segment_sizes:
78+
segment_angle = (size / total_size) * available_angle
79+
start_angle = current_angle
80+
end_angle = current_angle - segment_angle
81+
angles.append((start_angle, end_angle))
82+
current_angle = end_angle - gap_fraction
83+
84+
# Draw outer ring segments
85+
outer_radius = 1.0
86+
ring_width = 0.12
87+
88+
for i, (start, end) in enumerate(angles):
89+
theta = np.linspace(end, start, 50)
90+
inner = outer_radius - ring_width
91+
x_outer = outer_radius * np.cos(theta)
92+
y_outer = outer_radius * np.sin(theta)
93+
x_inner = inner * np.cos(theta[::-1])
94+
y_inner = inner * np.sin(theta[::-1])
95+
x = np.concatenate([x_outer, x_inner])
96+
y = np.concatenate([y_outer, y_inner])
97+
ax.fill(x, y, color=colors[i], alpha=0.85, edgecolor="white", linewidth=2)
98+
99+
# Add segment label with larger font
100+
mid_angle = (start + end) / 2
101+
label_radius = outer_radius + 0.14
102+
label_x = label_radius * np.cos(mid_angle)
103+
label_y = label_radius * np.sin(mid_angle)
104+
rotation_deg = np.degrees(mid_angle)
105+
norm_angle = rotation_deg % 360
106+
if 90 < norm_angle < 270:
107+
rotation = rotation_deg + 180
108+
ha = "right"
109+
else:
110+
rotation = rotation_deg
111+
ha = "left"
112+
ax.text(
113+
label_x,
114+
label_y,
115+
segments[i],
116+
ha=ha,
117+
va="center",
118+
fontsize=18,
119+
fontweight="bold",
120+
rotation=rotation,
121+
rotation_mode="anchor",
122+
)
123+
124+
# Draw inner data track (trade volume as bar heights)
125+
inner_track_outer = outer_radius - ring_width - 0.03
126+
inner_track_inner = inner_track_outer - 0.15
127+
128+
for i, (start, end) in enumerate(angles):
129+
height_fraction = segment_sizes[i] / segment_sizes.max()
130+
track_height = (inner_track_outer - inner_track_inner) * height_fraction
131+
theta = np.linspace(end, start, 30)
132+
inner = inner_track_outer - track_height
133+
x_outer = inner_track_outer * np.cos(theta)
134+
y_outer = inner_track_outer * np.sin(theta)
135+
x_inner = inner * np.cos(theta[::-1])
136+
y_inner = inner * np.sin(theta[::-1])
137+
x = np.concatenate([x_outer, x_inner])
138+
y = np.concatenate([y_outer, y_inner])
139+
ax.fill(x, y, color=colors[i], alpha=0.5, edgecolor="none")
140+
141+
# Draw ribbons (connections between segments) - inline bezier curve calculation
142+
ribbon_radius = inner_track_inner - 0.05
143+
max_value = max(c[2] for c in connections)
144+
min_value = min(c[2] for c in connections)
145+
ctrl_radius = ribbon_radius * 0.1
146+
n_points = 50
147+
t = np.linspace(0, 1, n_points)
148+
149+
for source, target, value in connections:
150+
# Improved width calculation: ensure minimum visibility for smaller values
151+
# Map values from min-max to 0.25-0.7 range for better distinction
152+
normalized_value = (value - min_value) / (max_value - min_value)
153+
width_fraction = 0.25 + normalized_value * 0.45
154+
155+
start1, end1 = angles[source]
156+
start2, end2 = angles[target]
157+
seg1_span = (start1 - end1) * width_fraction * 0.4
158+
seg2_span = (start2 - end2) * width_fraction * 0.4
159+
mid1 = (start1 + end1) / 2
160+
mid2 = (start2 + end2) / 2
161+
ribbon_start1 = mid1 + seg1_span / 2
162+
ribbon_end1 = mid1 - seg1_span / 2
163+
ribbon_start2 = mid2 + seg2_span / 2
164+
ribbon_end2 = mid2 - seg2_span / 2
165+
166+
# First bezier curve
167+
p0 = np.array([ribbon_radius * np.cos(ribbon_start1), ribbon_radius * np.sin(ribbon_start1)])
168+
p3 = np.array([ribbon_radius * np.cos(ribbon_start2), ribbon_radius * np.sin(ribbon_start2)])
169+
p1 = ctrl_radius * np.array([np.cos(ribbon_start1), np.sin(ribbon_start1)])
170+
p2 = ctrl_radius * np.array([np.cos(ribbon_start2), np.sin(ribbon_start2)])
171+
curve1 = (
172+
(1 - t)[:, None] ** 3 * p0
173+
+ 3 * (1 - t)[:, None] ** 2 * t[:, None] * p1
174+
+ 3 * (1 - t)[:, None] * t[:, None] ** 2 * p2
175+
+ t[:, None] ** 3 * p3
176+
)
177+
178+
# Second bezier curve
179+
p0 = np.array([ribbon_radius * np.cos(ribbon_end1), ribbon_radius * np.sin(ribbon_end1)])
180+
p3 = np.array([ribbon_radius * np.cos(ribbon_end2), ribbon_radius * np.sin(ribbon_end2)])
181+
p1 = ctrl_radius * np.array([np.cos(ribbon_end1), np.sin(ribbon_end1)])
182+
p2 = ctrl_radius * np.array([np.cos(ribbon_end2), np.sin(ribbon_end2)])
183+
curve2 = (
184+
(1 - t)[:, None] ** 3 * p0
185+
+ 3 * (1 - t)[:, None] ** 2 * t[:, None] * p1
186+
+ 3 * (1 - t)[:, None] * t[:, None] ** 2 * p2
187+
+ t[:, None] ** 3 * p3
188+
)
189+
190+
# Arcs at source and target segments
191+
arc1_angles = np.linspace(ribbon_start1, ribbon_end1, 10)
192+
arc1 = ribbon_radius * np.column_stack([np.cos(arc1_angles), np.sin(arc1_angles)])
193+
arc2_angles = np.linspace(ribbon_end2, ribbon_start2, 10)
194+
arc2 = ribbon_radius * np.column_stack([np.cos(arc2_angles), np.sin(arc2_angles)])
195+
196+
# Combine vertices and draw polygon
197+
vertices = np.vstack([arc1, curve1, arc2, curve2[::-1]])
198+
polygon = plt.Polygon(vertices, facecolor=colors[source], edgecolor="none", alpha=0.45, zorder=1)
199+
ax.add_patch(polygon)
200+
201+
# Configure axes
202+
ax.set_xlim(-1.7, 1.7)
203+
ax.set_ylim(-1.7, 1.7)
204+
ax.axis("off")
205+
206+
# Title with proper format: spec-id · library · pyplots.ai
207+
ax.set_title("circos-basic · seaborn · pyplots.ai", fontsize=28, fontweight="bold", pad=20)
208+
209+
# Add legend explaining the visualization
210+
legend_elements = [
211+
mpatches.Patch(facecolor=colors[0], alpha=0.85, label="Outer ring: Region (arc size ∝ total trade)"),
212+
mpatches.Patch(facecolor=colors[0], alpha=0.5, label="Inner track: Trade volume (bar height)"),
213+
mpatches.Patch(facecolor=colors[0], alpha=0.45, label="Ribbons: Trade flow (width ∝ value)"),
214+
]
215+
ax.legend(handles=legend_elements, loc="lower center", bbox_to_anchor=(0.5, -0.08), ncol=1, fontsize=16, frameon=False)
216+
217+
plt.tight_layout()
218+
plt.savefig("plot.png", dpi=300, bbox_inches="tight")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
library: seaborn
2+
specification_id: circos-basic
3+
created: '2025-12-31T11:07:32Z'
4+
updated: '2025-12-31T11:36:29Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617630017
7+
issue: 3005
8+
python_version: 3.13.11
9+
library_version: 0.13.2
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/circos-basic/seaborn/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/circos-basic/seaborn/plot_thumb.png
12+
preview_html: null
13+
quality_score: 82
14+
review:
15+
strengths:
16+
- Excellent circular layout with well-proportioned segments and gaps
17+
- Inner data track effectively shows secondary trade volume information
18+
- Ribbon bezier curves are smooth and visually appealing with proper transparency
19+
- Region labels are well-rotated and positioned for readability
20+
- Tab10 color palette provides good visual distinction between segments
21+
- Comprehensive legend explaining all visual components
22+
weaknesses:
23+
- Title format does not strictly follow the required pattern (spec-id should be
24+
first, not the descriptive title)
25+
- Seaborn is only used for theming and color palette, not for actual plotting functions
26+
- this is a limitation of the library for Circos plots
27+
- Some ribbons in the center appear quite dense and could benefit from slightly
28+
more transparency

0 commit comments

Comments
 (0)