Skip to content

Commit 649aab5

Browse files
feat(plotnine): implement circos-basic (#3147)
## Implementation: `circos-basic` - plotnine Implements the **plotnine** version of `circos-basic`. **File:** `plots/circos-basic/implementations/plotnine.py` **Parent Issue:** #3005 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20627531900)* --------- 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 6168b2b commit 649aab5

2 files changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
""" pyplots.ai
2+
circos-basic: Circos Plot
3+
Library: plotnine 0.15.2 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from plotnine import (
10+
aes,
11+
coord_fixed,
12+
element_blank,
13+
element_text,
14+
geom_path,
15+
geom_polygon,
16+
geom_text,
17+
ggplot,
18+
labs,
19+
scale_fill_manual,
20+
scale_x_continuous,
21+
scale_y_continuous,
22+
theme,
23+
)
24+
25+
26+
# Data - Trade flows between world regions (bidirectional connections)
27+
# Represents export relationships between major economic regions
28+
flows = [
29+
("Asia", "Europe", 85),
30+
("Asia", "North America", 72),
31+
("Asia", "Middle East", 45),
32+
("Asia", "Africa", 28),
33+
("Europe", "North America", 55),
34+
("Europe", "Asia", 48),
35+
("Europe", "Africa", 32),
36+
("Europe", "South America", 22),
37+
("North America", "Asia", 42),
38+
("North America", "Europe", 38),
39+
("North America", "South America", 35),
40+
("South America", "Europe", 28),
41+
("South America", "North America", 25),
42+
("South America", "Asia", 18),
43+
("Middle East", "Asia", 65),
44+
("Middle East", "Europe", 42),
45+
("Africa", "Europe", 38),
46+
("Africa", "Asia", 22),
47+
]
48+
49+
# Get unique segments and assign colors
50+
segments = list(dict.fromkeys([f[0] for f in flows] + [f[1] for f in flows]))
51+
colors = {
52+
"Asia": "#306998", # Python Blue
53+
"Europe": "#FFD43B", # Python Yellow
54+
"North America": "#2ECC71", # Green (more distinct from South America)
55+
"South America": "#E67E22", # Orange (clearly different from North America)
56+
"Middle East": "#E74C3C", # Red
57+
"Africa": "#9B59B6", # Purple
58+
}
59+
60+
# Calculate total flow for each segment (incoming + outgoing)
61+
segment_totals = dict.fromkeys(segments, 0)
62+
for src, tgt, val in flows:
63+
segment_totals[src] += val
64+
segment_totals[tgt] += val
65+
66+
total_flow = sum(segment_totals.values())
67+
68+
# Calculate arc positions around the circle
69+
gap_angle = 0.08 # Gap between segments in radians
70+
total_gap = gap_angle * len(segments)
71+
available_angle = 2 * np.pi - total_gap
72+
73+
# Assign angular positions to each segment (start at top)
74+
segment_arcs = {}
75+
current_angle = -np.pi / 2 # Start at top
76+
for segment in segments:
77+
arc_size = (segment_totals[segment] / total_flow) * available_angle
78+
segment_arcs[segment] = {
79+
"start": current_angle,
80+
"end": current_angle + arc_size,
81+
"mid": current_angle + arc_size / 2,
82+
}
83+
current_angle += arc_size + gap_angle
84+
85+
# Radii for the circos plot
86+
outer_radius = 1.0
87+
inner_radius = 0.92
88+
chord_radius = 0.88
89+
track_outer = 0.82 # Inner track for additional data
90+
track_inner = 0.72
91+
92+
# Build outer arc segments (the ring around the circle)
93+
arc_data = []
94+
n_arc_points = 80
95+
arc_id = 0
96+
for segment in segments:
97+
arc = segment_arcs[segment]
98+
angles = np.linspace(arc["start"], arc["end"], n_arc_points)
99+
100+
# Outer edge
101+
for angle in angles:
102+
arc_data.append(
103+
{
104+
"x": outer_radius * np.cos(angle),
105+
"y": outer_radius * np.sin(angle),
106+
"segment": segment,
107+
"arc_id": f"arc_{arc_id}",
108+
}
109+
)
110+
# Inner edge (reversed to close polygon)
111+
for angle in reversed(angles):
112+
arc_data.append(
113+
{
114+
"x": inner_radius * np.cos(angle),
115+
"y": inner_radius * np.sin(angle),
116+
"segment": segment,
117+
"arc_id": f"arc_{arc_id}",
118+
}
119+
)
120+
arc_id += 1
121+
122+
arc_df = pd.DataFrame(arc_data)
123+
124+
# Build inner data track (concentric ring showing segment "weight")
125+
# This represents additional data layer as mentioned in spec
126+
track_data = []
127+
track_id = 0
128+
for segment in segments:
129+
arc = segment_arcs[segment]
130+
angles = np.linspace(arc["start"], arc["end"], n_arc_points)
131+
132+
for angle in angles:
133+
track_data.append(
134+
{
135+
"x": track_outer * np.cos(angle),
136+
"y": track_outer * np.sin(angle),
137+
"segment": segment,
138+
"track_id": f"track_{track_id}",
139+
}
140+
)
141+
for angle in reversed(angles):
142+
track_data.append(
143+
{
144+
"x": track_inner * np.cos(angle),
145+
"y": track_inner * np.sin(angle),
146+
"segment": segment,
147+
"track_id": f"track_{track_id}",
148+
}
149+
)
150+
track_id += 1
151+
152+
track_df = pd.DataFrame(track_data)
153+
154+
# Track offsets within each segment for chord placement
155+
segment_offsets = {s: segment_arcs[s]["start"] for s in segments}
156+
157+
# Build ribbon/chord polygons connecting segments
158+
chord_data = []
159+
chord_id = 0
160+
n_bezier = 50
161+
162+
for src, tgt, val in flows:
163+
# Calculate angular width for this connection
164+
src_width = (val / total_flow) * available_angle * 0.5
165+
tgt_width = (val / total_flow) * available_angle * 0.5
166+
167+
# Source arc segment position
168+
src_start = segment_offsets[src]
169+
src_end = src_start + src_width
170+
segment_offsets[src] = src_end + 0.005
171+
172+
# Target arc segment position
173+
tgt_start = segment_offsets[tgt]
174+
tgt_end = tgt_start + tgt_width
175+
segment_offsets[tgt] = tgt_end + 0.005
176+
177+
# Build chord polygon with bezier curves
178+
polygon_x = []
179+
polygon_y = []
180+
181+
# Source arc (at chord_radius)
182+
src_angles = np.linspace(src_start, src_end, 15)
183+
for angle in src_angles:
184+
polygon_x.append(chord_radius * np.cos(angle))
185+
polygon_y.append(chord_radius * np.sin(angle))
186+
187+
# Bezier curve from source end to target start
188+
src_end_x = chord_radius * np.cos(src_end)
189+
src_end_y = chord_radius * np.sin(src_end)
190+
tgt_start_x = chord_radius * np.cos(tgt_start)
191+
tgt_start_y = chord_radius * np.sin(tgt_start)
192+
193+
for i in range(1, n_bezier):
194+
t = i / n_bezier
195+
# Quadratic bezier through origin for smooth ribbon
196+
x = (1 - t) ** 2 * src_end_x + 2 * (1 - t) * t * 0 + t**2 * tgt_start_x
197+
y = (1 - t) ** 2 * src_end_y + 2 * (1 - t) * t * 0 + t**2 * tgt_start_y
198+
polygon_x.append(x)
199+
polygon_y.append(y)
200+
201+
# Target arc (at chord_radius)
202+
tgt_angles = np.linspace(tgt_start, tgt_end, 15)
203+
for angle in tgt_angles:
204+
polygon_x.append(chord_radius * np.cos(angle))
205+
polygon_y.append(chord_radius * np.sin(angle))
206+
207+
# Bezier curve back from target end to source start
208+
tgt_end_x = chord_radius * np.cos(tgt_end)
209+
tgt_end_y = chord_radius * np.sin(tgt_end)
210+
src_start_x = chord_radius * np.cos(src_start)
211+
src_start_y = chord_radius * np.sin(src_start)
212+
213+
for i in range(1, n_bezier):
214+
t = i / n_bezier
215+
x = (1 - t) ** 2 * tgt_end_x + 2 * (1 - t) * t * 0 + t**2 * src_start_x
216+
y = (1 - t) ** 2 * tgt_end_y + 2 * (1 - t) * t * 0 + t**2 * src_start_y
217+
polygon_x.append(x)
218+
polygon_y.append(y)
219+
220+
# Add to dataframe
221+
for x, y in zip(polygon_x, polygon_y, strict=False):
222+
chord_data.append({"x": x, "y": y, "chord_id": f"chord_{chord_id}", "source": src, "target": tgt, "value": val})
223+
224+
chord_id += 1
225+
226+
chord_df = pd.DataFrame(chord_data)
227+
228+
# Create segment labels positioned outside the ring
229+
label_data = []
230+
label_radius = 1.15
231+
for segment in segments:
232+
arc = segment_arcs[segment]
233+
mid_angle = arc["mid"]
234+
label_data.append(
235+
{
236+
"x": label_radius * np.cos(mid_angle),
237+
"y": label_radius * np.sin(mid_angle),
238+
"label": segment,
239+
"segment": segment,
240+
}
241+
)
242+
243+
label_df = pd.DataFrame(label_data)
244+
245+
# Create circular gridlines for visual reference
246+
grid_rows = []
247+
for radius in [0.5, 0.7]:
248+
grid_angles = np.linspace(0, 2 * np.pi, 100)
249+
for angle in grid_angles:
250+
grid_rows.append({"x": radius * np.cos(angle), "y": radius * np.sin(angle), "radius": radius})
251+
252+
grid_df = pd.DataFrame(grid_rows)
253+
254+
# Build the circos plot
255+
plot = (
256+
ggplot()
257+
# Background gridlines (subtle circular references)
258+
+ geom_path(aes(x="x", y="y", group="radius"), data=grid_df, color="#EEEEEE", size=0.3, alpha=0.5)
259+
# Ribbons/chords connecting segments (drawn first, behind arcs)
260+
+ geom_polygon(
261+
aes(x="x", y="y", group="chord_id", fill="source"), data=chord_df, alpha=0.5, color="white", size=0.15
262+
)
263+
# Inner data track (concentric ring)
264+
+ geom_polygon(
265+
aes(x="x", y="y", group="track_id", fill="segment"), data=track_df, alpha=0.4, color="white", size=0.3
266+
)
267+
# Outer arc segments (the main circular ring)
268+
+ geom_polygon(aes(x="x", y="y", group="arc_id", fill="segment"), data=arc_df, alpha=0.95, color="white", size=0.8)
269+
# Segment labels
270+
+ geom_text(aes(x="x", y="y", label="label"), data=label_df, size=14, color="#2C3E50", fontweight="bold")
271+
# Color scale
272+
+ scale_fill_manual(values=colors, name="Region")
273+
# Equal aspect ratio for proper circles
274+
+ coord_fixed(ratio=1)
275+
+ scale_x_continuous(limits=(-1.6, 1.6), expand=(0, 0))
276+
+ scale_y_continuous(limits=(-1.5, 1.6), expand=(0, 0))
277+
# Title
278+
+ labs(title="circos-basic · plotnine · pyplots.ai")
279+
# Clean theme for circular plot
280+
+ theme(
281+
figure_size=(12, 12),
282+
plot_title=element_text(size=24, ha="center", fontweight="bold", margin={"b": 20}),
283+
plot_margin=0.08,
284+
axis_title=element_blank(),
285+
axis_text=element_blank(),
286+
axis_ticks=element_blank(),
287+
axis_line=element_blank(),
288+
panel_grid_major=element_blank(),
289+
panel_grid_minor=element_blank(),
290+
panel_background=element_blank(),
291+
plot_background=element_blank(),
292+
legend_title=element_text(size=16),
293+
legend_text=element_text(size=14),
294+
legend_position="right",
295+
)
296+
)
297+
298+
# Save as PNG (3600x3600 px at 300 dpi = 12x12 inches)
299+
plot.save("plot.png", dpi=300)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
library: plotnine
2+
specification_id: circos-basic
3+
created: '2025-12-31T21:36:00Z'
4+
updated: '2025-12-31T21:44:46Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20627531900
7+
issue: 3005
8+
python_version: 3.13.11
9+
library_version: 0.15.2
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/circos-basic/plotnine/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/circos-basic/plotnine/plot_thumb.png
12+
preview_html: null
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent implementation of a complex circos plot using only plotnine native grammar
17+
of graphics, without falling back to matplotlib
18+
- Beautiful color palette with 6 distinct, colorblind-accessible colors for regions
19+
- Proper chord/ribbon geometry with Bezier curves connecting segments through center
20+
- Clean, well-organized code with clear data structures for segments, arcs, chords,
21+
and tracks
22+
- Realistic trade flow scenario with meaningful bidirectional connections between
23+
world regions
24+
- Includes inner data track as specified, adding visual depth
25+
weaknesses:
26+
- Inner data track shows the same data as outer ring (segment totals) rather than
27+
different attribute data
28+
- Some chord ribbons are quite thin and could be more visible
29+
- Slight layout imbalance with more empty space at bottom of canvas

0 commit comments

Comments
 (0)