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