|
| 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") |
0 commit comments