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