|
| 1 | +""" pyplots.ai |
| 2 | +circos-basic: Circos Plot |
| 3 | +Library: plotly 6.5.0 | Python 3.13.11 |
| 4 | +Quality: 90/100 | Created: 2025-12-31 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import plotly.graph_objects as go |
| 9 | + |
| 10 | + |
| 11 | +# Data: Trade flows between regions (as example for circos visualization) |
| 12 | +np.random.seed(42) |
| 13 | + |
| 14 | +# Define 8 segments (regions) for the circular layout |
| 15 | +segments = ["North America", "Europe", "East Asia", "South America", "Africa", "Middle East", "South Asia", "Oceania"] |
| 16 | +n_segments = len(segments) |
| 17 | + |
| 18 | +# Segment sizes (proportional to economic importance) |
| 19 | +segment_sizes = np.array([25, 30, 28, 10, 8, 12, 15, 6]) |
| 20 | +segment_sizes = segment_sizes / segment_sizes.sum() * 360 # Normalize to 360 degrees |
| 21 | + |
| 22 | +# Connection matrix (trade flow values) |
| 23 | +# Random but symmetric-ish values for bilateral trade |
| 24 | +connections = np.array( |
| 25 | + [ |
| 26 | + [0, 45, 60, 15, 5, 10, 8, 12], # North America |
| 27 | + [40, 0, 35, 12, 18, 25, 15, 8], # Europe |
| 28 | + [55, 38, 0, 10, 12, 20, 30, 18], # East Asia |
| 29 | + [12, 10, 8, 0, 8, 3, 4, 5], # South America |
| 30 | + [6, 20, 10, 10, 0, 15, 6, 2], # Africa |
| 31 | + [12, 28, 22, 4, 12, 0, 18, 5], # Middle East |
| 32 | + [10, 18, 35, 5, 8, 22, 0, 8], # South Asia |
| 33 | + [15, 10, 22, 6, 3, 6, 10, 0], # Oceania |
| 34 | + ] |
| 35 | +) |
| 36 | + |
| 37 | +# Colors for each segment |
| 38 | +colors = ["#306998", "#FFD43B", "#E34234", "#2ECC71", "#9B59B6", "#E67E22", "#1ABC9C", "#3498DB"] |
| 39 | + |
| 40 | + |
| 41 | +# Helper to blend two hex colors |
| 42 | +def blend_colors(c1, c2, ratio=0.5): |
| 43 | + """Blend two hex colors. ratio=0 gives c1, ratio=1 gives c2.""" |
| 44 | + r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16) |
| 45 | + r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16) |
| 46 | + r = int(r1 * (1 - ratio) + r2 * ratio) |
| 47 | + g = int(g1 * (1 - ratio) + g2 * ratio) |
| 48 | + b = int(b1 * (1 - ratio) + b2 * ratio) |
| 49 | + return f"#{r:02x}{g:02x}{b:02x}" |
| 50 | + |
| 51 | + |
| 52 | +# Calculate segment positions on the circle |
| 53 | +gap = 2 # Gap between segments in degrees |
| 54 | +total_gap = gap * n_segments |
| 55 | +available = 360 - total_gap |
| 56 | +segment_angles = segment_sizes / segment_sizes.sum() * available |
| 57 | + |
| 58 | +# Starting angles for each segment |
| 59 | +start_angles = np.zeros(n_segments) |
| 60 | +for i in range(1, n_segments): |
| 61 | + start_angles[i] = start_angles[i - 1] + segment_angles[i - 1] + gap |
| 62 | + |
| 63 | +# Create figure |
| 64 | +fig = go.Figure() |
| 65 | + |
| 66 | +# Outer ring radius |
| 67 | +outer_r = 1.0 |
| 68 | +inner_r = 0.85 |
| 69 | +ribbon_inner = 0.80 |
| 70 | + |
| 71 | +# Draw outer segments (arcs) |
| 72 | +for i in range(n_segments): |
| 73 | + theta_start = start_angles[i] |
| 74 | + theta_end = theta_start + segment_angles[i] |
| 75 | + |
| 76 | + # Create arc points |
| 77 | + theta = np.linspace(np.radians(theta_start), np.radians(theta_end), 50) |
| 78 | + theta_rev = theta[::-1] |
| 79 | + |
| 80 | + # Outer arc |
| 81 | + x_outer = outer_r * np.cos(theta) |
| 82 | + y_outer = outer_r * np.sin(theta) |
| 83 | + |
| 84 | + # Inner arc (for the segment) |
| 85 | + x_inner = inner_r * np.cos(theta_rev) |
| 86 | + y_inner = inner_r * np.sin(theta_rev) |
| 87 | + |
| 88 | + # Combine to make a filled arc |
| 89 | + x_arc = np.concatenate([x_outer, x_inner, [x_outer[0]]]) |
| 90 | + y_arc = np.concatenate([y_outer, y_inner, [y_outer[0]]]) |
| 91 | + |
| 92 | + fig.add_trace( |
| 93 | + go.Scatter( |
| 94 | + x=x_arc, |
| 95 | + y=y_arc, |
| 96 | + fill="toself", |
| 97 | + fillcolor=colors[i], |
| 98 | + line=dict(color="white", width=1), |
| 99 | + name=segments[i], |
| 100 | + hoverinfo="name", |
| 101 | + showlegend=True, |
| 102 | + ) |
| 103 | + ) |
| 104 | + |
| 105 | + # Add label for segment |
| 106 | + mid_angle = np.radians((theta_start + theta_end) / 2) |
| 107 | + label_r = outer_r + 0.12 |
| 108 | + label_x = label_r * np.cos(mid_angle) |
| 109 | + label_y = label_r * np.sin(mid_angle) |
| 110 | + |
| 111 | + # Rotate text based on position for better readability |
| 112 | + text_angle = (theta_start + theta_end) / 2 |
| 113 | + if 90 < text_angle < 270: |
| 114 | + text_angle = text_angle - 180 |
| 115 | + |
| 116 | + # Adjust text anchor based on position for less cramping |
| 117 | + mid_deg = (theta_start + theta_end) / 2 |
| 118 | + if 45 < mid_deg < 135: |
| 119 | + xanchor = "center" |
| 120 | + yanchor = "bottom" |
| 121 | + elif 225 < mid_deg < 315: |
| 122 | + xanchor = "center" |
| 123 | + yanchor = "top" |
| 124 | + elif mid_deg <= 45 or mid_deg >= 315: |
| 125 | + xanchor = "left" |
| 126 | + yanchor = "middle" |
| 127 | + else: |
| 128 | + xanchor = "right" |
| 129 | + yanchor = "middle" |
| 130 | + |
| 131 | + fig.add_annotation( |
| 132 | + x=label_x, |
| 133 | + y=label_y, |
| 134 | + text=segments[i], |
| 135 | + showarrow=False, |
| 136 | + font=dict(size=16, color="#333333"), |
| 137 | + textangle=-text_angle, |
| 138 | + xanchor=xanchor, |
| 139 | + yanchor=yanchor, |
| 140 | + ) |
| 141 | + |
| 142 | +# Draw ribbons (connections between segments) |
| 143 | +# Get midpoint angles for each segment |
| 144 | +mid_angles = start_angles + segment_angles / 2 |
| 145 | + |
| 146 | +# Track positions within each segment for ribbon placement |
| 147 | +segment_positions = np.zeros(n_segments) |
| 148 | + |
| 149 | +# Draw connections as curved ribbons |
| 150 | +for i in range(n_segments): |
| 151 | + for j in range(i + 1, n_segments): |
| 152 | + if connections[i, j] > 5: # Only show significant connections |
| 153 | + # Normalize ribbon width |
| 154 | + max_conn = connections.max() |
| 155 | + width_i = (connections[i, j] / max_conn) * segment_angles[i] * 0.3 |
| 156 | + width_j = (connections[i, j] / max_conn) * segment_angles[j] * 0.3 |
| 157 | + |
| 158 | + # Source positions |
| 159 | + theta_i_start = start_angles[i] + segment_positions[i] |
| 160 | + theta_i_end = theta_i_start + width_i |
| 161 | + segment_positions[i] += width_i + 1 |
| 162 | + |
| 163 | + # Target positions |
| 164 | + theta_j_start = start_angles[j] + segment_positions[j] |
| 165 | + theta_j_end = theta_j_start + width_j |
| 166 | + segment_positions[j] += width_j + 1 |
| 167 | + |
| 168 | + # Create bezier-like ribbon using multiple points |
| 169 | + n_points = 30 |
| 170 | + |
| 171 | + # Source arc points |
| 172 | + theta_src = np.linspace(np.radians(theta_i_start), np.radians(theta_i_end), 10) |
| 173 | + x_src = ribbon_inner * np.cos(theta_src) |
| 174 | + y_src = ribbon_inner * np.sin(theta_src) |
| 175 | + |
| 176 | + # Target arc points |
| 177 | + theta_tgt = np.linspace(np.radians(theta_j_start), np.radians(theta_j_end), 10) |
| 178 | + x_tgt = ribbon_inner * np.cos(theta_tgt) |
| 179 | + y_tgt = ribbon_inner * np.sin(theta_tgt) |
| 180 | + |
| 181 | + # Create curved path through center |
| 182 | + # Bezier-like curve from source to target |
| 183 | + t = np.linspace(0, 1, n_points) |
| 184 | + |
| 185 | + # Control points - curve through center with some offset |
| 186 | + cp1_x, cp1_y = 0.2 * x_src[-1], 0.2 * y_src[-1] |
| 187 | + cp2_x, cp2_y = 0.2 * x_tgt[0], 0.2 * y_tgt[0] |
| 188 | + |
| 189 | + # Quadratic bezier for top edge |
| 190 | + curve1_x = (1 - t) ** 2 * x_src[-1] + 2 * (1 - t) * t * cp1_x + t**2 * x_tgt[0] |
| 191 | + curve1_y = (1 - t) ** 2 * y_src[-1] + 2 * (1 - t) * t * cp1_y + t**2 * y_tgt[0] |
| 192 | + |
| 193 | + # Control points for bottom edge |
| 194 | + cp3_x, cp3_y = 0.2 * x_tgt[-1], 0.2 * y_tgt[-1] |
| 195 | + cp4_x, cp4_y = 0.2 * x_src[0], 0.2 * y_src[0] |
| 196 | + |
| 197 | + # Quadratic bezier for bottom edge (reversed) |
| 198 | + curve2_x = (1 - t) ** 2 * x_tgt[-1] + 2 * (1 - t) * t * cp3_x + t**2 * x_src[0] |
| 199 | + curve2_y = (1 - t) ** 2 * y_tgt[-1] + 2 * (1 - t) * t * cp3_y + t**2 * y_src[0] |
| 200 | + |
| 201 | + # Combine all points to form ribbon shape |
| 202 | + x_ribbon = np.concatenate([x_src, curve1_x, x_tgt, curve2_x, [x_src[0]]]) |
| 203 | + y_ribbon = np.concatenate([y_src, curve1_y, y_tgt, curve2_y, [y_src[0]]]) |
| 204 | + |
| 205 | + # Blend colors from source and target segments for better visual connection |
| 206 | + ribbon_color = blend_colors(colors[i], colors[j], 0.5) |
| 207 | + fig.add_trace( |
| 208 | + go.Scatter( |
| 209 | + x=x_ribbon, |
| 210 | + y=y_ribbon, |
| 211 | + fill="toself", |
| 212 | + fillcolor=ribbon_color, |
| 213 | + opacity=0.5, |
| 214 | + line=dict(color="white", width=0.5), |
| 215 | + hoverinfo="text", |
| 216 | + hovertext=f"{segments[i]} ↔ {segments[j]}: {connections[i, j]}", |
| 217 | + showlegend=False, |
| 218 | + ) |
| 219 | + ) |
| 220 | + |
| 221 | +# Add inner track (simulated data - e.g., GDP values as bar heights) |
| 222 | +track_r_outer = 0.78 |
| 223 | +track_r_inner = 0.60 |
| 224 | +track_values = np.array([0.8, 0.95, 0.9, 0.4, 0.25, 0.5, 0.55, 0.3]) |
| 225 | + |
| 226 | +for i in range(n_segments): |
| 227 | + theta_start = start_angles[i] |
| 228 | + theta_end = theta_start + segment_angles[i] |
| 229 | + |
| 230 | + theta = np.linspace(np.radians(theta_start), np.radians(theta_end), 30) |
| 231 | + theta_rev = theta[::-1] |
| 232 | + |
| 233 | + # Height based on track value |
| 234 | + height = track_r_inner + (track_r_outer - track_r_inner) * track_values[i] |
| 235 | + |
| 236 | + x_outer = height * np.cos(theta) |
| 237 | + y_outer = height * np.sin(theta) |
| 238 | + x_inner = track_r_inner * np.cos(theta_rev) |
| 239 | + y_inner = track_r_inner * np.sin(theta_rev) |
| 240 | + |
| 241 | + x_bar = np.concatenate([x_outer, x_inner, [x_outer[0]]]) |
| 242 | + y_bar = np.concatenate([y_outer, y_inner, [y_outer[0]]]) |
| 243 | + |
| 244 | + fig.add_trace( |
| 245 | + go.Scatter( |
| 246 | + x=x_bar, |
| 247 | + y=y_bar, |
| 248 | + fill="toself", |
| 249 | + fillcolor=colors[i], |
| 250 | + opacity=0.6, |
| 251 | + line=dict(color="white", width=0.5), |
| 252 | + hoverinfo="text", |
| 253 | + hovertext=f"{segments[i]} GDP Index: {track_values[i]:.2f}", |
| 254 | + showlegend=False, |
| 255 | + ) |
| 256 | + ) |
| 257 | + |
| 258 | +# Update layout |
| 259 | +fig.update_layout( |
| 260 | + title=dict(text="circos-basic · plotly · pyplots.ai", font=dict(size=28, color="#333333"), x=0.5, xanchor="center"), |
| 261 | + showlegend=True, |
| 262 | + legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5, font=dict(size=14)), |
| 263 | + xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-1.5, 1.5], scaleanchor="y", scaleratio=1), |
| 264 | + yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-1.5, 1.5]), |
| 265 | + plot_bgcolor="white", |
| 266 | + paper_bgcolor="white", |
| 267 | + margin=dict(l=50, r=50, t=100, b=120), |
| 268 | +) |
| 269 | + |
| 270 | +# Save as PNG (4800x2700 equivalent via scale) |
| 271 | +fig.write_image("plot.png", width=1600, height=900, scale=3) |
| 272 | + |
| 273 | +# Save interactive HTML version |
| 274 | +fig.write_html("plot.html", include_plotlyjs=True, full_html=True) |
0 commit comments