Skip to content

Commit 6168b2b

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

2 files changed

Lines changed: 393 additions & 0 deletions

File tree

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
""" pyplots.ai
2+
circos-basic: Circos Plot
3+
Library: altair 6.0.0 | Python 3.13.11
4+
Quality: 90/100 | Created: 2025-12-31
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data: Software module dependencies
13+
np.random.seed(42)
14+
15+
# Define segments (software modules)
16+
segments = ["Core", "API", "Database", "Auth", "Cache", "Queue", "Logger", "Config"]
17+
n_segments = len(segments)
18+
19+
# Segment sizes (relative importance/size of each module)
20+
segment_sizes = np.array([25, 20, 18, 15, 12, 10, 8, 6])
21+
segment_sizes_normalized = segment_sizes / segment_sizes.sum()
22+
23+
# Connection matrix (dependencies between modules)
24+
connections = [
25+
("Core", "API", 15),
26+
("Core", "Database", 12),
27+
("Core", "Logger", 8),
28+
("API", "Auth", 10),
29+
("API", "Cache", 8),
30+
("Database", "Cache", 6),
31+
("Database", "Logger", 5),
32+
("Auth", "Logger", 4),
33+
("Queue", "Logger", 7),
34+
("Queue", "Database", 5),
35+
("Config", "Core", 9),
36+
("Config", "Logger", 3),
37+
("Cache", "Logger", 4),
38+
("API", "Queue", 6),
39+
]
40+
41+
# Inner track data (simulated importance/activity values)
42+
track_data = np.random.uniform(0.3, 1.0, n_segments)
43+
44+
# Colors for each segment (colorblind-safe palette)
45+
colors = {
46+
"Core": "#306998", # Python Blue
47+
"API": "#FFD43B", # Python Yellow
48+
"Database": "#2E8B57", # Sea Green
49+
"Auth": "#DC143C", # Crimson
50+
"Cache": "#9370DB", # Medium Purple
51+
"Queue": "#20B2AA", # Light Sea Green
52+
"Logger": "#FF8C00", # Dark Orange
53+
"Config": "#708090", # Slate Gray
54+
}
55+
56+
# Target output: 3600x3600 px (1:1 aspect ratio for circular plot) with scale_factor=3.0
57+
# Internal canvas: 1200x1200 pixels
58+
width = 1200
59+
height = 1200
60+
center_x = width / 2
61+
center_y = height / 2
62+
63+
# Circle parameters
64+
outer_radius = 400
65+
inner_radius = 360
66+
track_outer_radius = 340
67+
track_inner_radius = 280
68+
ribbon_radius = 270
69+
70+
# Calculate segment positions
71+
gap = 0.05 # Gap between segments in radians
72+
total_gap = gap * n_segments
73+
available_angle = 2 * np.pi - total_gap
74+
segment_angles = segment_sizes_normalized * available_angle
75+
76+
# Calculate start and end angles for each segment (starting at top)
77+
start_angle = np.pi / 2
78+
segment_arcs = {}
79+
current_angle = start_angle
80+
81+
for i, name in enumerate(segments):
82+
arc_angle = segment_angles[i]
83+
segment_arcs[name] = {"start": current_angle, "end": current_angle - arc_angle, "angle": arc_angle, "idx": i}
84+
current_angle = current_angle - arc_angle - gap
85+
86+
segment_dict = {name: i for i, name in enumerate(segments)}
87+
88+
# Create outer ring segments data
89+
n_arc_points = 50
90+
outer_ring_data = []
91+
92+
for name in segments:
93+
arc = segment_arcs[name]
94+
theta = np.linspace(arc["end"], arc["start"], n_arc_points)
95+
96+
# Outer arc (clockwise)
97+
for j, angle in enumerate(theta):
98+
outer_ring_data.append(
99+
{
100+
"segment": name,
101+
"x": center_x + outer_radius * np.cos(angle),
102+
"y": center_y + outer_radius * np.sin(angle),
103+
"order": j,
104+
"color": colors[name],
105+
}
106+
)
107+
108+
# Inner arc (counter-clockwise to close the shape)
109+
for j, angle in enumerate(reversed(theta)):
110+
outer_ring_data.append(
111+
{
112+
"segment": name,
113+
"x": center_x + inner_radius * np.cos(angle),
114+
"y": center_y + inner_radius * np.sin(angle),
115+
"order": n_arc_points + j,
116+
"color": colors[name],
117+
}
118+
)
119+
120+
outer_ring_df = pd.DataFrame(outer_ring_data)
121+
122+
# Create inner track data (concentric data track)
123+
inner_track_data = []
124+
125+
for i, name in enumerate(segments):
126+
arc = segment_arcs[name]
127+
theta = np.linspace(arc["end"], arc["start"], n_arc_points)
128+
129+
# Height proportional to track data value
130+
track_height = (track_outer_radius - track_inner_radius) * track_data[i]
131+
actual_outer = track_inner_radius + track_height
132+
133+
# Outer arc
134+
for j, angle in enumerate(theta):
135+
inner_track_data.append(
136+
{
137+
"segment": name,
138+
"x": center_x + actual_outer * np.cos(angle),
139+
"y": center_y + actual_outer * np.sin(angle),
140+
"order": j,
141+
"value": track_data[i],
142+
}
143+
)
144+
145+
# Inner arc (counter-clockwise)
146+
for j, angle in enumerate(reversed(theta)):
147+
inner_track_data.append(
148+
{
149+
"segment": name,
150+
"x": center_x + track_inner_radius * np.cos(angle),
151+
"y": center_y + track_inner_radius * np.sin(angle),
152+
"order": n_arc_points + j,
153+
"value": track_data[i],
154+
}
155+
)
156+
157+
inner_track_df = pd.DataFrame(inner_track_data)
158+
159+
# Create ribbons (connections between segments)
160+
max_value = max(c[2] for c in connections)
161+
n_ribbon_points = 30
162+
ribbons_data = []
163+
ribbon_id = 0
164+
165+
for source, target, value in connections:
166+
arc1 = segment_arcs[source]
167+
arc2 = segment_arcs[target]
168+
169+
# Calculate positions at segment midpoints
170+
mid1 = (arc1["start"] + arc1["end"]) / 2
171+
mid2 = (arc2["start"] + arc2["end"]) / 2
172+
173+
# Ribbon width proportional to value (minimum 0.04 for visibility)
174+
width_factor = max(0.04, value / max_value * 0.12)
175+
176+
# Points for source segment
177+
angle1_start = mid1 - width_factor
178+
angle1_end = mid1 + width_factor
179+
180+
# Points for target segment
181+
angle2_start = mid2 - width_factor
182+
angle2_end = mid2 + width_factor
183+
184+
ribbon_points = []
185+
186+
# Arc at source
187+
src_angles = np.linspace(angle1_start, angle1_end, 8)
188+
for angle in src_angles:
189+
ribbon_points.append((center_x + ribbon_radius * np.cos(angle), center_y + ribbon_radius * np.sin(angle)))
190+
191+
# Bezier curve from source end to target start
192+
for i in range(n_ribbon_points):
193+
t = i / (n_ribbon_points - 1)
194+
# Quadratic bezier with control point at center
195+
x = (
196+
(1 - t) ** 2 * (center_x + ribbon_radius * np.cos(angle1_end))
197+
+ 2 * (1 - t) * t * center_x
198+
+ t**2 * (center_x + ribbon_radius * np.cos(angle2_start))
199+
)
200+
y = (
201+
(1 - t) ** 2 * (center_y + ribbon_radius * np.sin(angle1_end))
202+
+ 2 * (1 - t) * t * center_y
203+
+ t**2 * (center_y + ribbon_radius * np.sin(angle2_start))
204+
)
205+
ribbon_points.append((x, y))
206+
207+
# Arc at target
208+
tgt_angles = np.linspace(angle2_start, angle2_end, 8)
209+
for angle in tgt_angles:
210+
ribbon_points.append((center_x + ribbon_radius * np.cos(angle), center_y + ribbon_radius * np.sin(angle)))
211+
212+
# Bezier curve from target end back to source start
213+
for i in range(n_ribbon_points):
214+
t = i / (n_ribbon_points - 1)
215+
x = (
216+
(1 - t) ** 2 * (center_x + ribbon_radius * np.cos(angle2_end))
217+
+ 2 * (1 - t) * t * center_x
218+
+ t**2 * (center_x + ribbon_radius * np.cos(angle1_start))
219+
)
220+
y = (
221+
(1 - t) ** 2 * (center_y + ribbon_radius * np.sin(angle2_end))
222+
+ 2 * (1 - t) * t * center_y
223+
+ t**2 * (center_y + ribbon_radius * np.sin(angle1_start))
224+
)
225+
ribbon_points.append((x, y))
226+
227+
# Add points to dataframe
228+
for pt_idx, (x, y) in enumerate(ribbon_points):
229+
ribbons_data.append(
230+
{
231+
"ribbon_id": f"{source}-{target}-{ribbon_id}",
232+
"source": source,
233+
"target": target,
234+
"value": value,
235+
"x": x,
236+
"y": y,
237+
"order": pt_idx,
238+
}
239+
)
240+
241+
ribbon_id += 1
242+
243+
ribbons_df = pd.DataFrame(ribbons_data)
244+
245+
# Create segment labels data
246+
labels_data = []
247+
for name in segments:
248+
arc = segment_arcs[name]
249+
mid_angle = (arc["start"] + arc["end"]) / 2
250+
label_radius = outer_radius + 45
251+
252+
labels_data.append(
253+
{
254+
"segment": name,
255+
"x": center_x + label_radius * np.cos(mid_angle),
256+
"y": center_y + label_radius * np.sin(mid_angle),
257+
}
258+
)
259+
260+
labels_df = pd.DataFrame(labels_data)
261+
262+
# Create outer ring chart
263+
outer_ring_chart = (
264+
alt.Chart(outer_ring_df)
265+
.mark_line(filled=True, strokeWidth=1, stroke="white")
266+
.encode(
267+
x=alt.X("x:Q", scale=alt.Scale(domain=[0, width]), axis=None),
268+
y=alt.Y("y:Q", scale=alt.Scale(domain=[0, height]), axis=None),
269+
color=alt.Color(
270+
"segment:N",
271+
scale=alt.Scale(domain=list(colors.keys()), range=list(colors.values())),
272+
legend=alt.Legend(title="Modules", titleFontSize=18, labelFontSize=14, orient="right", symbolSize=200),
273+
),
274+
detail="segment:N",
275+
order="order:Q",
276+
)
277+
)
278+
279+
# Define darker shades for inner track (to distinguish from outer ring)
280+
inner_colors = {
281+
"Core": "#1E4A6E", # Darker Python Blue
282+
"API": "#C4A12B", # Darker Python Yellow
283+
"Database": "#1E6B42", # Darker Sea Green
284+
"Auth": "#A01030", # Darker Crimson
285+
"Cache": "#6A4AAB", # Darker Medium Purple
286+
"Queue": "#18877D", # Darker Light Sea Green
287+
"Logger": "#C46B00", # Darker Dark Orange
288+
"Config": "#505A64", # Darker Slate Gray
289+
}
290+
291+
# Create inner track chart with distinct styling
292+
inner_track_chart = (
293+
alt.Chart(inner_track_df)
294+
.mark_line(filled=True, strokeWidth=2, stroke="#333333", opacity=0.85)
295+
.encode(
296+
x=alt.X("x:Q", scale=alt.Scale(domain=[0, width]), axis=None),
297+
y=alt.Y("y:Q", scale=alt.Scale(domain=[0, height]), axis=None),
298+
color=alt.Color(
299+
"segment:N",
300+
scale=alt.Scale(domain=list(inner_colors.keys()), range=list(inner_colors.values())),
301+
legend=None,
302+
),
303+
detail="segment:N",
304+
order="order:Q",
305+
)
306+
)
307+
308+
# Create ribbons chart
309+
ribbons_chart = (
310+
alt.Chart(ribbons_df)
311+
.mark_line(filled=True, opacity=0.5, strokeWidth=0)
312+
.encode(
313+
x=alt.X("x:Q", scale=alt.Scale(domain=[0, width]), axis=None),
314+
y=alt.Y("y:Q", scale=alt.Scale(domain=[0, height]), axis=None),
315+
color=alt.Color(
316+
"source:N", scale=alt.Scale(domain=list(colors.keys()), range=list(colors.values())), legend=None
317+
),
318+
detail="ribbon_id:N",
319+
order="order:Q",
320+
tooltip=[
321+
alt.Tooltip("source:N", title="From"),
322+
alt.Tooltip("target:N", title="To"),
323+
alt.Tooltip("value:Q", title="Dependency"),
324+
],
325+
)
326+
)
327+
328+
# Create labels chart with larger font for 3600px canvas
329+
labels_chart = (
330+
alt.Chart(labels_df)
331+
.mark_text(fontSize=22, fontWeight="bold")
332+
.encode(
333+
x=alt.X("x:Q", scale=alt.Scale(domain=[0, width])),
334+
y=alt.Y("y:Q", scale=alt.Scale(domain=[0, height])),
335+
text="segment:N",
336+
color=alt.Color(
337+
"segment:N", scale=alt.Scale(domain=list(colors.keys()), range=list(colors.values())), legend=None
338+
),
339+
)
340+
)
341+
342+
# Combine all layers
343+
chart = (
344+
alt.layer(ribbons_chart, inner_track_chart, outer_ring_chart, labels_chart)
345+
.properties(
346+
width=width,
347+
height=height,
348+
title=alt.Title(text="circos-basic · altair · pyplots.ai", fontSize=28, anchor="middle"),
349+
)
350+
.configure_view(strokeWidth=0)
351+
.configure_legend(
352+
padding=15,
353+
cornerRadius=5,
354+
fillColor="#FFFFFF",
355+
strokeColor="#CCCCCC",
356+
strokeWidth=1,
357+
titleFontSize=20,
358+
labelFontSize=16,
359+
symbolSize=250,
360+
offset=20,
361+
)
362+
)
363+
364+
# Save as PNG (3600x3600 px with scale_factor=3.0)
365+
chart.save("plot.png", scale_factor=3.0)
366+
chart.save("plot.html")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
library: altair
2+
specification_id: circos-basic
3+
created: '2025-12-31T21:35:04Z'
4+
updated: '2025-12-31T21:44:02Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20627531457
7+
issue: 3005
8+
python_version: 3.13.11
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/circos-basic/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/circos-basic/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/circos-basic/altair/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent circular layout with proper segment proportions and gaps
17+
- Creative use of Altair mark_line with filled=True to create arc shapes
18+
- Well-implemented Bezier curves for ribbon connections
19+
- Good color palette that maintains segment identity throughout
20+
- Inner track provides additional data dimension with darker shades
21+
- Clean readable code structure with clear data definitions
22+
- Tooltips added to ribbons for interactivity
23+
weaknesses:
24+
- Some ribbons in the center appear slightly congested making individual connections
25+
harder to trace
26+
- Inner track bars could have more visible separation from each other
27+
- Legend shows segment colors but not inner track or ribbon meaning

0 commit comments

Comments
 (0)