|
1 | | -""" pyplots.ai |
| 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: /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 |
|
37 | 36 | arc_starts = [] |
38 | 37 | arc_ends = [] |
39 | 38 | current_pos = 0 |
40 | | -for _, total in enumerate(totals): |
| 39 | +for total in totals: |
41 | 40 | arc_starts.append(current_pos) |
42 | 41 | arc_ends.append(current_pos + (total / total_flow) * (1 - n * gap)) |
43 | 42 | current_pos = arc_ends[-1] + gap |
|
47 | 46 |
|
48 | 47 | # Draw outer arcs for each continent |
49 | 48 | for i in range(n): |
50 | | - # Generate arc points (outer) |
51 | 49 | angles_outer = np.linspace(2 * np.pi * arc_starts[i] - np.pi / 2, 2 * np.pi * arc_ends[i] - np.pi / 2, 100) |
52 | 50 | x_outer = 1.0 * np.cos(angles_outer) |
53 | 51 | y_outer = 1.0 * np.sin(angles_outer) |
54 | 52 |
|
55 | | - # Generate arc points (inner, reversed) |
56 | 53 | angles_inner = np.linspace(2 * np.pi * arc_ends[i] - np.pi / 2, 2 * np.pi * arc_starts[i] - np.pi / 2, 100) |
57 | 54 | x_inner = 0.95 * np.cos(angles_inner) |
58 | 55 | y_inner = 0.95 * np.sin(angles_inner) |
|
63 | 60 | y=np.concatenate([y_outer, y_inner]), |
64 | 61 | fill="toself", |
65 | 62 | fillcolor=colors[i], |
66 | | - line=dict(color="white", width=1), |
67 | | - hoverinfo="text", |
68 | | - text=f"{continents[i]}<br>Total flow: {totals[i]}", |
| 63 | + line={"color": "white", "width": 1}, |
| 64 | + hovertemplate=(f"<b>{continents[i]}</b><br>Total flow: {int(totals[i])}M people<extra></extra>"), |
69 | 65 | name=continents[i], |
70 | 66 | showlegend=True, |
71 | 67 | ) |
72 | 68 | ) |
73 | 69 |
|
74 | | -# Draw chords between continents |
75 | | -shapes = [] |
| 70 | +# Draw chords as interactive traces (not shapes) for hover support |
76 | 71 | for i in range(n): |
77 | 72 | src_pos = arc_starts[i] |
78 | 73 | 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 | | - ) |
88 | | - |
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 | | - ) |
111 | | - |
112 | | - shapes.append( |
113 | | - dict(type="path", path=path, fillcolor=colors[i], opacity=0.6, line=dict(color=colors[i], width=0.5)) |
| 74 | + if i == j or flow_matrix[i, j] == 0: |
| 75 | + continue |
| 76 | + |
| 77 | + flow = flow_matrix[i, j] |
| 78 | + chord_width = (flow / total_flow) * (1 - n * gap) |
| 79 | + |
| 80 | + # Target position offset based on prior incoming flows |
| 81 | + tgt_base = arc_starts[j] |
| 82 | + tgt_offset = sum((flow_matrix[k, j] / total_flow) * (1 - n * gap) for k in range(i) if flow_matrix[k, j] > 0) |
| 83 | + |
| 84 | + # Source arc endpoints |
| 85 | + src_angle1 = 2 * np.pi * src_pos - np.pi / 2 |
| 86 | + src_angle2 = 2 * np.pi * (src_pos + chord_width) - np.pi / 2 |
| 87 | + sx1, sy1 = 0.95 * np.cos(src_angle1), 0.95 * np.sin(src_angle1) |
| 88 | + sx2, sy2 = 0.95 * np.cos(src_angle2), 0.95 * np.sin(src_angle2) |
| 89 | + |
| 90 | + # Target arc endpoints |
| 91 | + tgt_start = tgt_base + tgt_offset |
| 92 | + tgt_end = tgt_start + chord_width |
| 93 | + tgt_angle1 = 2 * np.pi * tgt_start - np.pi / 2 |
| 94 | + tgt_angle2 = 2 * np.pi * tgt_end - np.pi / 2 |
| 95 | + tx1, ty1 = 0.95 * np.cos(tgt_angle1), 0.95 * np.sin(tgt_angle1) |
| 96 | + tx2, ty2 = 0.95 * np.cos(tgt_angle2), 0.95 * np.sin(tgt_angle2) |
| 97 | + |
| 98 | + # Build chord path: source arc -> bezier to target -> target arc -> bezier back |
| 99 | + # Source side arc points |
| 100 | + src_angles = np.linspace(src_angle1, src_angle2, 20) |
| 101 | + src_x = 0.95 * np.cos(src_angles) |
| 102 | + src_y = 0.95 * np.sin(src_angles) |
| 103 | + |
| 104 | + # Bezier from source end to target start (through center) |
| 105 | + t = np.linspace(0, 1, 30) |
| 106 | + bez1_x = (1 - t) ** 2 * sx2 + 2 * (1 - t) * t * 0 + t**2 * tx1 |
| 107 | + bez1_y = (1 - t) ** 2 * sy2 + 2 * (1 - t) * t * 0 + t**2 * ty1 |
| 108 | + |
| 109 | + # Target side arc points |
| 110 | + tgt_angles = np.linspace(tgt_angle1, tgt_angle2, 20) |
| 111 | + tgt_x = 0.95 * np.cos(tgt_angles) |
| 112 | + tgt_y = 0.95 * np.sin(tgt_angles) |
| 113 | + |
| 114 | + # Bezier from target end back to source start (through center) |
| 115 | + bez2_x = (1 - t) ** 2 * tx2 + 2 * (1 - t) * t * 0 + t**2 * sx1 |
| 116 | + bez2_y = (1 - t) ** 2 * ty2 + 2 * (1 - t) * t * 0 + t**2 * sy1 |
| 117 | + |
| 118 | + # Combine into closed polygon |
| 119 | + chord_x = np.concatenate([src_x, bez1_x, tgt_x, bez2_x]) |
| 120 | + chord_y = np.concatenate([src_y, bez1_y, tgt_y, bez2_y]) |
| 121 | + |
| 122 | + fig.add_trace( |
| 123 | + go.Scatter( |
| 124 | + x=chord_x, |
| 125 | + y=chord_y, |
| 126 | + fill="toself", |
| 127 | + fillcolor=colors[i], |
| 128 | + opacity=0.55, |
| 129 | + line={"color": colors[i], "width": 0.5}, |
| 130 | + hovertemplate=(f"<b>{continents[i]} → {continents[j]}</b><br>Flow: {flow}M people<extra></extra>"), |
| 131 | + showlegend=False, |
| 132 | + hoveron="fills", |
114 | 133 | ) |
| 134 | + ) |
115 | 135 |
|
116 | | - src_pos += chord_width |
| 136 | + src_pos += chord_width |
117 | 137 |
|
118 | 138 | # Add continent labels around the perimeter |
119 | 139 | for i in range(n): |
120 | 140 | mid_pos = (arc_starts[i] + arc_ends[i]) / 2 |
121 | 141 | angle = 2 * np.pi * mid_pos - np.pi / 2 |
122 | 142 | label_radius = 1.12 |
123 | 143 |
|
124 | | - # Rotate text for readability |
125 | 144 | text_angle_deg = np.degrees(angle) |
126 | 145 | if 90 < text_angle_deg < 270 or -270 < text_angle_deg < -90: |
127 | 146 | text_angle_deg += 180 |
|
131 | 150 | x=label_radius * np.cos(angle), |
132 | 151 | y=label_radius * np.sin(angle), |
133 | 152 | text=f"<b>{continents[i]}</b>", |
134 | | - font=dict(size=18, color=colors[i]), |
| 153 | + font={"size": 22, "color": colors[i]}, |
135 | 154 | showarrow=False, |
136 | 155 | textangle=rotation, |
137 | 156 | ) |
138 | 157 |
|
139 | 158 | # Layout |
140 | 159 | 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", |
146 | | - ), |
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"), |
| 160 | + title={ |
| 161 | + "text": "Migration Flows Between Continents · chord-basic · plotly · pyplots.ai", |
| 162 | + "font": {"size": 30, "color": "#333333"}, |
| 163 | + "x": 0.5, |
| 164 | + "xanchor": "center", |
| 165 | + "y": 0.97, |
| 166 | + }, |
| 167 | + xaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "range": [-1.35, 1.35]}, |
| 168 | + yaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "range": [-1.35, 1.35], "scaleanchor": "x"}, |
150 | 169 | template="plotly_white", |
151 | 170 | 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), |
| 171 | + legend={"font": {"size": 18}, "x": 1.01, "y": 0.5, "yanchor": "middle", "tracegroupgap": 5}, |
| 172 | + margin={"l": 30, "r": 160, "t": 80, "b": 30}, |
154 | 173 | plot_bgcolor="white", |
155 | 174 | paper_bgcolor="white", |
| 175 | + hovermode="closest", |
156 | 176 | ) |
157 | 177 |
|
158 | 178 | # Save outputs |
|
0 commit comments