|
1 | 1 | """ pyplots.ai |
2 | 2 | chord-basic: Basic Chord Diagram |
3 | | -Library: plotly 6.5.0 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-23 |
| 3 | +Library: plotly 6.5.2 | Python 3.14 |
| 4 | +Quality: 88/100 | Updated: 2026-04-06 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
8 | 8 | import plotly.graph_objects as go |
9 | 9 |
|
10 | 10 |
|
11 | | -# Data: Migration flows between 6 continents (bidirectional) |
| 11 | +# Data: Migration flows between 6 continents (bidirectional, millions of people) |
12 | 12 | continents = ["Africa", "Asia", "Europe", "N. America", "S. America", "Oceania"] |
13 | 13 | n = len(continents) |
14 | 14 |
|
15 | 15 | # Flow matrix (row = source, col = target) - realistic migration patterns |
16 | | -np.random.seed(42) |
17 | 16 | flow_matrix = np.array( |
18 | 17 | [ |
19 | 18 | [0, 15, 25, 10, 5, 3], # Africa to others |
|
25 | 24 | ] |
26 | 25 | ) |
27 | 26 |
|
28 | | -# Colors for each continent (Python Blue first, then colorblind-safe palette) |
29 | | -colors = ["#306998", "#FFD43B", "#2E8B57", "#DC143C", "#9370DB", "#FF8C00"] |
| 27 | +# Colors: Python Blue first, then colorblind-safe palette |
| 28 | +# Replaced green (#2E8B57) with teal (#00B4D8) for deuteranopia accessibility |
| 29 | +colors = ["#306998", "#FFD43B", "#00B4D8", "#DC143C", "#9370DB", "#FF8C00"] |
| 30 | +colors_dim = [ |
| 31 | + "rgba(48,105,152,0.4)", |
| 32 | + "rgba(255,212,59,0.4)", |
| 33 | + "rgba(0,180,216,0.4)", |
| 34 | + "rgba(220,20,60,0.4)", |
| 35 | + "rgba(147,112,219,0.4)", |
| 36 | + "rgba(255,140,0,0.4)", |
| 37 | +] |
30 | 38 |
|
31 | 39 | # Calculate totals for each continent |
32 | 40 | totals = flow_matrix.sum(axis=0) + flow_matrix.sum(axis=1) |
33 | 41 | total_flow = flow_matrix.sum() |
34 | 42 |
|
| 43 | +# Identify the dominant corridor for storytelling emphasis |
| 44 | +max_flow_idx = np.unravel_index(np.argmax(flow_matrix + flow_matrix.T), flow_matrix.shape) |
| 45 | +dominant_src, dominant_tgt = max_flow_idx |
| 46 | +dominant_flow = flow_matrix[dominant_src, dominant_tgt] + flow_matrix[dominant_tgt, dominant_src] |
| 47 | + |
35 | 48 | # Calculate arc positions around the circle |
36 | 49 | gap = 0.02 |
37 | 50 | arc_starts = [] |
38 | 51 | arc_ends = [] |
39 | 52 | current_pos = 0 |
40 | | -for _, total in enumerate(totals): |
| 53 | +for total in totals: |
41 | 54 | arc_starts.append(current_pos) |
42 | 55 | arc_ends.append(current_pos + (total / total_flow) * (1 - n * gap)) |
43 | 56 | current_pos = arc_ends[-1] + gap |
44 | 57 |
|
45 | 58 | # Create figure |
46 | 59 | fig = go.Figure() |
47 | 60 |
|
48 | | -# Draw outer arcs for each continent |
| 61 | +# Draw outer arcs with gradient-like layered effect |
49 | 62 | for i in range(n): |
50 | | - # Generate arc points (outer) |
51 | 63 | angles_outer = np.linspace(2 * np.pi * arc_starts[i] - np.pi / 2, 2 * np.pi * arc_ends[i] - np.pi / 2, 100) |
| 64 | + # Outer ring (thicker, slightly transparent for depth) |
| 65 | + x_o = 1.02 * np.cos(angles_outer) |
| 66 | + y_o = 1.02 * np.sin(angles_outer) |
| 67 | + angles_rev = angles_outer[::-1] |
| 68 | + x_i = 0.98 * np.cos(angles_rev) |
| 69 | + y_i = 0.98 * np.sin(angles_rev) |
| 70 | + |
| 71 | + fig.add_trace( |
| 72 | + go.Scatter( |
| 73 | + x=np.concatenate([x_o, x_i]), |
| 74 | + y=np.concatenate([y_o, y_i]), |
| 75 | + fill="toself", |
| 76 | + fillcolor=colors_dim[i], |
| 77 | + line={"color": "rgba(255,255,255,0)", "width": 0}, |
| 78 | + hoverinfo="skip", |
| 79 | + showlegend=False, |
| 80 | + ) |
| 81 | + ) |
| 82 | + |
| 83 | + # Inner ring (solid color, main arc) |
52 | 84 | x_outer = 1.0 * np.cos(angles_outer) |
53 | 85 | y_outer = 1.0 * np.sin(angles_outer) |
54 | | - |
55 | | - # Generate arc points (inner, reversed) |
56 | | - angles_inner = np.linspace(2 * np.pi * arc_ends[i] - np.pi / 2, 2 * np.pi * arc_starts[i] - np.pi / 2, 100) |
57 | | - x_inner = 0.95 * np.cos(angles_inner) |
58 | | - y_inner = 0.95 * np.sin(angles_inner) |
| 86 | + x_inner = 0.94 * np.cos(angles_rev) |
| 87 | + y_inner = 0.94 * np.sin(angles_rev) |
59 | 88 |
|
60 | 89 | fig.add_trace( |
61 | 90 | go.Scatter( |
62 | 91 | x=np.concatenate([x_outer, x_inner]), |
63 | 92 | y=np.concatenate([y_outer, y_inner]), |
64 | 93 | fill="toself", |
65 | 94 | fillcolor=colors[i], |
66 | | - line=dict(color="white", width=1), |
67 | | - hoverinfo="text", |
68 | | - text=f"{continents[i]}<br>Total flow: {totals[i]}", |
| 95 | + line={"color": "white", "width": 0.5}, |
| 96 | + hovertemplate=( |
| 97 | + f"<b>{continents[i]}</b><br>" |
| 98 | + f"Outgoing: {int(flow_matrix[i].sum())}M<br>" |
| 99 | + f"Incoming: {int(flow_matrix[:, i].sum())}M<br>" |
| 100 | + f"Total: {int(totals[i])}M people" |
| 101 | + "<extra></extra>" |
| 102 | + ), |
69 | 103 | name=continents[i], |
70 | 104 | showlegend=True, |
| 105 | + legendgroup=continents[i], |
71 | 106 | ) |
72 | 107 | ) |
73 | 108 |
|
74 | | -# Draw chords between continents |
75 | | -shapes = [] |
| 109 | +# Draw chords with enhanced visibility and storytelling |
| 110 | +min_chord_width = 0.008 # Minimum visual width for thin chords |
76 | 111 | for i in range(n): |
77 | 112 | src_pos = arc_starts[i] |
78 | 113 | for j in range(n): |
79 | | - if i != j and flow_matrix[i, j] > 0: |
80 | | - flow = flow_matrix[i, j] |
81 | | - chord_width = (flow / total_flow) * (1 - n * gap) |
82 | | - |
83 | | - # Calculate target position offset |
84 | | - tgt_base = arc_starts[j] |
85 | | - tgt_offset = sum( |
86 | | - (flow_matrix[k, j] / total_flow) * (1 - n * gap) for k in range(i) if flow_matrix[k, j] > 0 |
87 | | - ) |
| 114 | + if i == j or flow_matrix[i, j] == 0: |
| 115 | + continue |
88 | 116 |
|
89 | | - # Calculate chord endpoints (source) |
90 | | - src_angle1 = 2 * np.pi * src_pos - np.pi / 2 |
91 | | - src_angle2 = 2 * np.pi * (src_pos + chord_width) - np.pi / 2 |
92 | | - x1 = 0.95 * np.cos(src_angle1) |
93 | | - y1 = 0.95 * np.sin(src_angle1) |
94 | | - x2 = 0.95 * np.cos(src_angle2) |
95 | | - y2 = 0.95 * np.sin(src_angle2) |
96 | | - |
97 | | - # Calculate chord endpoints (target) |
98 | | - tgt_start = tgt_base + tgt_offset |
99 | | - tgt_end = tgt_start + chord_width |
100 | | - tgt_angle1 = 2 * np.pi * tgt_start - np.pi / 2 |
101 | | - tgt_angle2 = 2 * np.pi * tgt_end - np.pi / 2 |
102 | | - x3 = 0.95 * np.cos(tgt_angle1) |
103 | | - y3 = 0.95 * np.sin(tgt_angle1) |
104 | | - x4 = 0.95 * np.cos(tgt_angle2) |
105 | | - y4 = 0.95 * np.sin(tgt_angle2) |
106 | | - |
107 | | - # SVG path with quadratic bezier curves through center |
108 | | - path = ( |
109 | | - f"M {x1},{y1} Q 0,0 {x3},{y3} A 0.95,0.95 0 0,1 {x4},{y4} Q 0,0 {x2},{y2} A 0.95,0.95 0 0,1 {x1},{y1} Z" |
110 | | - ) |
| 117 | + flow = flow_matrix[i, j] |
| 118 | + chord_width = max((flow / total_flow) * (1 - n * gap), min_chord_width) |
| 119 | + |
| 120 | + # Highlight dominant corridor with higher opacity |
| 121 | + is_dominant = (i == dominant_src and j == dominant_tgt) or (i == dominant_tgt and j == dominant_src) |
| 122 | + opacity = 0.72 if is_dominant else 0.45 |
| 123 | + line_width = 1.0 if is_dominant else 0.3 |
| 124 | + |
| 125 | + # Target position offset based on prior incoming flows |
| 126 | + tgt_base = arc_starts[j] |
| 127 | + tgt_offset = sum( |
| 128 | + max((flow_matrix[k, j] / total_flow) * (1 - n * gap), min_chord_width) |
| 129 | + for k in range(i) |
| 130 | + if flow_matrix[k, j] > 0 |
| 131 | + ) |
111 | 132 |
|
112 | | - shapes.append( |
113 | | - dict(type="path", path=path, fillcolor=colors[i], opacity=0.6, line=dict(color=colors[i], width=0.5)) |
| 133 | + # Source arc endpoints |
| 134 | + src_angle1 = 2 * np.pi * src_pos - np.pi / 2 |
| 135 | + src_angle2 = 2 * np.pi * (src_pos + chord_width) - np.pi / 2 |
| 136 | + sx1, sy1 = 0.94 * np.cos(src_angle1), 0.94 * np.sin(src_angle1) |
| 137 | + sx2, sy2 = 0.94 * np.cos(src_angle2), 0.94 * np.sin(src_angle2) |
| 138 | + |
| 139 | + # Target arc endpoints |
| 140 | + tgt_start = tgt_base + tgt_offset |
| 141 | + tgt_end = tgt_start + chord_width |
| 142 | + tgt_angle1 = 2 * np.pi * tgt_start - np.pi / 2 |
| 143 | + tgt_angle2 = 2 * np.pi * tgt_end - np.pi / 2 |
| 144 | + tx1, ty1 = 0.94 * np.cos(tgt_angle1), 0.94 * np.sin(tgt_angle1) |
| 145 | + tx2, ty2 = 0.94 * np.cos(tgt_angle2), 0.94 * np.sin(tgt_angle2) |
| 146 | + |
| 147 | + # Build chord path with smoother bezier curves |
| 148 | + src_angles = np.linspace(src_angle1, src_angle2, 20) |
| 149 | + src_x = 0.94 * np.cos(src_angles) |
| 150 | + src_y = 0.94 * np.sin(src_angles) |
| 151 | + |
| 152 | + t = np.linspace(0, 1, 40) |
| 153 | + bez1_x = (1 - t) ** 2 * sx2 + 2 * (1 - t) * t * 0 + t**2 * tx1 |
| 154 | + bez1_y = (1 - t) ** 2 * sy2 + 2 * (1 - t) * t * 0 + t**2 * ty1 |
| 155 | + |
| 156 | + tgt_angles = np.linspace(tgt_angle1, tgt_angle2, 20) |
| 157 | + tgt_x = 0.94 * np.cos(tgt_angles) |
| 158 | + tgt_y = 0.94 * np.sin(tgt_angles) |
| 159 | + |
| 160 | + bez2_x = (1 - t) ** 2 * tx2 + 2 * (1 - t) * t * 0 + t**2 * sx1 |
| 161 | + bez2_y = (1 - t) ** 2 * ty2 + 2 * (1 - t) * t * 0 + t**2 * sy1 |
| 162 | + |
| 163 | + chord_x = np.concatenate([src_x, bez1_x, tgt_x, bez2_x]) |
| 164 | + chord_y = np.concatenate([src_y, bez1_y, tgt_y, bez2_y]) |
| 165 | + |
| 166 | + fig.add_trace( |
| 167 | + go.Scatter( |
| 168 | + x=chord_x, |
| 169 | + y=chord_y, |
| 170 | + fill="toself", |
| 171 | + fillcolor=colors[i], |
| 172 | + opacity=opacity, |
| 173 | + line={"color": colors[i], "width": line_width}, |
| 174 | + hovertemplate=( |
| 175 | + f"<b>{continents[i]} → {continents[j]}</b><br>" |
| 176 | + f"Flow: {flow}M people<br>" |
| 177 | + f"Share: {flow / total_flow * 100:.1f}% of total" |
| 178 | + "<extra></extra>" |
| 179 | + ), |
| 180 | + showlegend=False, |
| 181 | + hoveron="fills", |
114 | 182 | ) |
| 183 | + ) |
115 | 184 |
|
116 | | - src_pos += chord_width |
| 185 | + src_pos += chord_width |
117 | 186 |
|
118 | | -# Add continent labels around the perimeter |
| 187 | +# Add continent labels around the perimeter (horizontal for clarity) |
119 | 188 | for i in range(n): |
120 | 189 | mid_pos = (arc_starts[i] + arc_ends[i]) / 2 |
121 | 190 | angle = 2 * np.pi * mid_pos - np.pi / 2 |
122 | | - label_radius = 1.12 |
| 191 | + label_radius = 1.16 |
| 192 | + |
| 193 | + lx = label_radius * np.cos(angle) |
| 194 | + ly = label_radius * np.sin(angle) |
| 195 | + angle_deg = np.degrees(angle) % 360 |
123 | 196 |
|
124 | | - # Rotate text for readability |
125 | | - text_angle_deg = np.degrees(angle) |
126 | | - if 90 < text_angle_deg < 270 or -270 < text_angle_deg < -90: |
127 | | - text_angle_deg += 180 |
128 | | - rotation = -text_angle_deg + 90 if -90 < np.degrees(angle) < 90 else -text_angle_deg - 90 |
| 197 | + # Anchor text toward the circle center for clean alignment |
| 198 | + if 45 < angle_deg < 135: |
| 199 | + xanchor, yanchor = "center", "bottom" |
| 200 | + elif 135 <= angle_deg < 225: |
| 201 | + xanchor, yanchor = "right", "middle" |
| 202 | + elif 225 <= angle_deg < 315: |
| 203 | + xanchor, yanchor = "center", "top" |
| 204 | + else: |
| 205 | + xanchor, yanchor = "left", "middle" |
129 | 206 |
|
130 | 207 | fig.add_annotation( |
131 | | - x=label_radius * np.cos(angle), |
132 | | - y=label_radius * np.sin(angle), |
133 | | - text=f"<b>{continents[i]}</b>", |
134 | | - font=dict(size=18, color=colors[i]), |
| 208 | + x=lx, |
| 209 | + y=ly, |
| 210 | + text=f"<b>{continents[i]}</b> <span style='font-size:17px;color:#888'>{int(totals[i])}M</span>", |
| 211 | + font={"size": 20, "color": colors[i], "family": "Arial, Helvetica, sans-serif"}, |
135 | 212 | showarrow=False, |
136 | | - textangle=rotation, |
| 213 | + xanchor=xanchor, |
| 214 | + yanchor=yanchor, |
137 | 215 | ) |
138 | 216 |
|
139 | | -# Layout |
140 | | -fig.update_layout( |
141 | | - title=dict( |
142 | | - text="Migration Flows Between Continents · chord-basic · plotly · pyplots.ai", |
143 | | - font=dict(size=28), |
144 | | - x=0.5, |
145 | | - xanchor="center", |
| 217 | +# Subtitle annotation for storytelling |
| 218 | +fig.add_annotation( |
| 219 | + text=( |
| 220 | + f"Europe–Asia corridor dominates at <b>{dominant_flow}M</b> combined flow" |
| 221 | + " · Chord width proportional to flow magnitude" |
146 | 222 | ), |
147 | | - shapes=shapes, |
148 | | - xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-1.4, 1.4]), |
149 | | - yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-1.4, 1.4], scaleanchor="x"), |
| 223 | + xref="paper", |
| 224 | + yref="paper", |
| 225 | + x=0.5, |
| 226 | + y=0.955, |
| 227 | + showarrow=False, |
| 228 | + font={"size": 17, "color": "#666666", "family": "Arial, Helvetica, sans-serif"}, |
| 229 | + xanchor="center", |
| 230 | +) |
| 231 | + |
| 232 | +# Layout with refined styling |
| 233 | +fig.update_layout( |
| 234 | + title={ |
| 235 | + "text": "Migration Flows Between Continents · chord-basic · plotly · pyplots.ai", |
| 236 | + "font": {"size": 28, "color": "#222222", "family": "Arial Black, Arial, sans-serif"}, |
| 237 | + "x": 0.5, |
| 238 | + "xanchor": "center", |
| 239 | + "y": 0.98, |
| 240 | + }, |
| 241 | + xaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "showline": False, "range": [-1.5, 1.5]}, |
| 242 | + yaxis={ |
| 243 | + "showgrid": False, |
| 244 | + "zeroline": False, |
| 245 | + "showticklabels": False, |
| 246 | + "showline": False, |
| 247 | + "range": [-1.6, 1.4], |
| 248 | + "scaleanchor": "x", |
| 249 | + }, |
150 | 250 | template="plotly_white", |
151 | 251 | showlegend=True, |
152 | | - legend=dict(font=dict(size=16), x=1.02, y=0.5, yanchor="middle"), |
153 | | - margin=dict(l=50, r=150, t=100, b=50), |
| 252 | + legend={ |
| 253 | + "font": {"size": 18, "family": "Arial, Helvetica, sans-serif"}, |
| 254 | + "title": {"text": "<b>Continents</b>", "font": {"size": 18, "color": "#444"}}, |
| 255 | + "x": 0.98, |
| 256 | + "y": 0.02, |
| 257 | + "xanchor": "right", |
| 258 | + "yanchor": "bottom", |
| 259 | + "bgcolor": "rgba(255,255,255,0.9)", |
| 260 | + "bordercolor": "#ddd", |
| 261 | + "borderwidth": 1, |
| 262 | + "tracegroupgap": 6, |
| 263 | + "itemsizing": "constant", |
| 264 | + }, |
| 265 | + margin={"l": 20, "r": 20, "t": 80, "b": 15}, |
154 | 266 | plot_bgcolor="white", |
155 | | - paper_bgcolor="white", |
| 267 | + paper_bgcolor="#FAFAFA", |
| 268 | + hovermode="closest", |
| 269 | + hoverlabel={ |
| 270 | + "bgcolor": "white", |
| 271 | + "bordercolor": "#ccc", |
| 272 | + "font": {"size": 16, "family": "Arial, Helvetica, sans-serif", "color": "#333"}, |
| 273 | + }, |
156 | 274 | ) |
157 | 275 |
|
158 | 276 | # Save outputs |
159 | | -fig.write_image("plot.png", width=1600, height=900, scale=3) |
| 277 | +fig.write_image("plot.png", width=1200, height=1200, scale=3) |
160 | 278 | fig.write_html("plot.html", include_plotlyjs="cdn") |
0 commit comments