|
1 | 1 | """ pyplots.ai |
2 | 2 | chord-basic: Basic Chord Diagram |
3 | | -Library: matplotlib 3.10.8 | Python 3.13.11 |
4 | | -Quality: 98/100 | Created: 2025-12-14 |
| 3 | +Library: matplotlib 3.10.8 | Python 3.14 |
| 4 | +Quality: 90/100 | Created: 2026-04-06 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import matplotlib.patches as mpatches |
|
11 | 11 |
|
12 | 12 |
|
13 | 13 | # Data: Migration flows between continents (in millions) |
14 | | -np.random.seed(42) |
15 | 14 | entities = ["Africa", "Asia", "Europe", "N. America", "S. America", "Oceania"] |
16 | 15 | n = len(entities) |
17 | 16 |
|
18 | | -# Flow matrix (row=source, col=target) |
19 | 17 | flow_matrix = np.array( |
20 | 18 | [ |
21 | 19 | [0, 12, 8, 5, 2, 1], # From Africa |
|
27 | 25 | ] |
28 | 26 | ) |
29 | 27 |
|
30 | | -# Colors for each entity (colorblind-safe palette) |
31 | | -colors = ["#306998", "#FFD43B", "#4ECDC4", "#FF6B6B", "#95E1A3", "#DDA0DD"] |
| 28 | +# Colorblind-safe palette starting with Python Blue |
| 29 | +colors = ["#306998", "#E69F00", "#009E73", "#D55E00", "#56B4E9", "#CC79A7"] |
32 | 30 |
|
33 | | -# Calculate totals for each entity (sum of outgoing + incoming) |
| 31 | +# Calculate entity totals and arc geometry |
34 | 32 | totals = flow_matrix.sum(axis=1) + flow_matrix.sum(axis=0) |
35 | 33 | total_flow = totals.sum() |
| 34 | +gap_deg = 3 |
| 35 | +available_deg = 360 - gap_deg * n |
| 36 | +arc_spans = (totals / total_flow) * available_deg |
36 | 37 |
|
37 | | -# Gap between entity arcs (in degrees) |
38 | | -gap = 3 |
39 | | -total_gap = gap * n |
40 | | -available_degrees = 360 - total_gap |
41 | | - |
42 | | -# Calculate arc spans for each entity |
43 | | -arc_spans = (totals / total_flow) * available_degrees |
44 | | - |
45 | | -# Calculate start angles for each entity arc |
| 38 | +# Start angles (clockwise from top) |
46 | 39 | start_angles = np.zeros(n) |
47 | | -current_angle = 90 # Start from top |
| 40 | +angle = 90 |
48 | 41 | for i in range(n): |
49 | | - start_angles[i] = current_angle |
50 | | - current_angle -= arc_spans[i] + gap |
51 | | - |
52 | | -# Create figure |
53 | | -fig, ax = plt.subplots(figsize=(16, 9), subplot_kw={"aspect": "equal"}) |
54 | | -ax.set_xlim(-1.5, 1.5) |
55 | | -ax.set_ylim(-1.3, 1.3) |
| 42 | + start_angles[i] = angle |
| 43 | + angle -= arc_spans[i] + gap_deg |
| 44 | + |
| 45 | +# Plot — square canvas for circular chart |
| 46 | +fig, ax = plt.subplots(figsize=(12, 12), subplot_kw={"aspect": "equal"}) |
| 47 | +fig.set_facecolor("#FAFAFA") |
| 48 | +ax.set_xlim(-1.45, 1.45) |
| 49 | +ax.set_ylim(-1.45, 1.45) |
| 50 | +ax.set_facecolor("#FAFAFA") |
56 | 51 | ax.axis("off") |
57 | 52 |
|
58 | | -# Draw entity arcs on the outer ring |
59 | 53 | radius = 1.0 |
60 | | -arc_width = 0.08 |
| 54 | +arc_width = 0.09 |
| 55 | +inner_r = radius - arc_width |
61 | 56 |
|
| 57 | +# Draw outer arcs |
62 | 58 | for i in range(n): |
63 | 59 | theta1 = start_angles[i] - arc_spans[i] |
64 | 60 | theta2 = start_angles[i] |
65 | | - |
66 | | - # Draw outer arc as a wedge |
67 | 61 | wedge = mpatches.Wedge( |
68 | 62 | (0, 0), radius, theta1, theta2, width=arc_width, facecolor=colors[i], edgecolor="white", linewidth=2 |
69 | 63 | ) |
70 | 64 | ax.add_patch(wedge) |
71 | 65 |
|
72 | | - # Add entity label |
73 | | - mid_angle = (theta1 + theta2) / 2 |
74 | | - label_radius = radius + 0.12 |
75 | | - label_x = label_radius * np.cos(np.radians(mid_angle)) |
76 | | - label_y = label_radius * np.sin(np.radians(mid_angle)) |
| 66 | + # Label placement |
| 67 | + mid = np.radians((theta1 + theta2) / 2) |
| 68 | + lx, ly = (radius + 0.12) * np.cos(mid), (radius + 0.12) * np.sin(mid) |
| 69 | + mid_deg = np.degrees(mid) % 360 |
| 70 | + ha = ( |
| 71 | + "center" |
| 72 | + if mid_deg < 15 or mid_deg > 345 or 165 < mid_deg < 195 |
| 73 | + else ("right" if 90 < mid_deg < 270 else "left") |
| 74 | + ) |
| 75 | + ax.text(lx, ly, entities[i], fontsize=20, fontweight="bold", ha=ha, va="center", color=colors[i]) |
77 | 76 |
|
78 | | - # Rotate text to align with arc |
79 | | - rotation = mid_angle |
80 | | - if mid_angle > 90 or mid_angle < -90: |
81 | | - rotation = mid_angle + 180 |
82 | | - if 90 < mid_angle < 270 or -270 < mid_angle < -90: |
83 | | - ha = "right" |
84 | | - else: |
85 | | - ha = "left" |
86 | | - if abs(mid_angle) < 10 or abs(mid_angle - 180) < 10 or abs(mid_angle + 180) < 10: |
87 | | - ha = "center" |
| 77 | +# Track angular position within each arc for chord placement |
| 78 | +arc_cursors = start_angles.copy() |
| 79 | +unit_angles = arc_spans / totals |
88 | 80 |
|
89 | | - ax.text(label_x, label_y, entities[i], fontsize=18, fontweight="bold", ha=ha, va="center", color=colors[i]) |
| 81 | +# Sort flows by magnitude (draw largest last for visual hierarchy) |
| 82 | +flows = [(i, j, flow_matrix[i, j]) for i in range(n) for j in range(n) if i != j and flow_matrix[i, j] > 0] |
| 83 | +flows.sort(key=lambda f: f[2]) |
90 | 84 |
|
| 85 | +# Pre-compute chord positions to avoid cursor interference from draw order |
| 86 | +min_chord_deg = 1.5 # minimum angular span for visibility |
| 87 | +chord_params = [] |
| 88 | +pos_cursors = start_angles.copy() |
| 89 | +for i in range(n): |
| 90 | + for j in range(n): |
| 91 | + if i != j and flow_matrix[i, j] > 0: |
| 92 | + flow = flow_matrix[i, j] |
| 93 | + src_span = max(flow * unit_angles[i], min_chord_deg) |
| 94 | + src_end = pos_cursors[i] |
| 95 | + src_start = src_end - src_span |
| 96 | + pos_cursors[i] = src_start |
91 | 97 |
|
92 | | -def draw_chord(ax, start1, end1, start2, end2, color, alpha=0.65): |
93 | | - """Draw a chord between two arcs using Bezier curves.""" |
94 | | - inner_radius = radius - arc_width |
| 98 | + tgt_span = max(flow * unit_angles[j], min_chord_deg) |
| 99 | + tgt_end = pos_cursors[j] |
| 100 | + tgt_start = tgt_end - tgt_span |
| 101 | + pos_cursors[j] = tgt_start |
95 | 102 |
|
96 | | - # Convert angles to radians |
97 | | - s1, e1 = np.radians(start1), np.radians(end1) |
98 | | - s2, e2 = np.radians(start2), np.radians(end2) |
| 103 | + chord_params.append((src_start, src_end, tgt_start, tgt_end, colors[i], flow)) |
99 | 104 |
|
100 | | - # Create path points |
101 | | - n_arc_points = 20 |
| 105 | +# Sort by flow magnitude so largest chords render on top |
| 106 | +chord_params.sort(key=lambda c: c[5]) |
102 | 107 |
|
103 | | - # First arc (source) |
104 | | - arc1_angles = np.linspace(s1, e1, n_arc_points) |
105 | | - arc1_points = np.column_stack([inner_radius * np.cos(arc1_angles), inner_radius * np.sin(arc1_angles)]) |
| 108 | +# Draw chords using cubic Bezier paths |
| 109 | +n_arc_pts = 30 |
| 110 | +ctrl_factor = 0.25 |
106 | 111 |
|
107 | | - # Second arc (target) |
108 | | - arc2_angles = np.linspace(s2, e2, n_arc_points) |
109 | | - arc2_points = np.column_stack([inner_radius * np.cos(arc2_angles), inner_radius * np.sin(arc2_angles)]) |
| 112 | +for src_start, src_end, tgt_start, tgt_end, color, flow in chord_params: |
| 113 | + s1, e1 = np.radians(src_start), np.radians(src_end) |
| 114 | + s2, e2 = np.radians(tgt_start), np.radians(tgt_end) |
110 | 115 |
|
111 | | - # Control points for Bezier curves (through center with some offset) |
112 | | - ctrl_factor = 0.3 |
| 116 | + arc1_t = np.linspace(s1, e1, n_arc_pts) |
| 117 | + arc1 = np.column_stack([inner_r * np.cos(arc1_t), inner_r * np.sin(arc1_t)]) |
113 | 118 |
|
114 | | - # Build path: arc1 -> bezier to arc2 -> arc2 -> bezier back to arc1 |
115 | | - verts = [] |
116 | | - codes = [] |
| 119 | + arc2_t = np.linspace(s2, e2, n_arc_pts) |
| 120 | + arc2 = np.column_stack([inner_r * np.cos(arc2_t), inner_r * np.sin(arc2_t)]) |
117 | 121 |
|
118 | | - # Start at first point of arc1 |
119 | | - verts.append(arc1_points[0]) |
120 | | - codes.append(Path.MOVETO) |
| 122 | + # Build closed path: arc1 → bezier → arc2 → bezier → close |
| 123 | + verts = [arc1[0]] |
| 124 | + codes = [Path.MOVETO] |
121 | 125 |
|
122 | | - # Arc1 points |
123 | | - for pt in arc1_points[1:]: |
| 126 | + for pt in arc1[1:]: |
124 | 127 | verts.append(pt) |
125 | 128 | codes.append(Path.LINETO) |
126 | 129 |
|
127 | | - # Bezier curve from end of arc1 to start of arc2 |
128 | | - ctrl1 = arc1_points[-1] * ctrl_factor |
129 | | - ctrl2 = arc2_points[0] * ctrl_factor |
130 | | - verts.extend([ctrl1, ctrl2, arc2_points[0]]) |
| 130 | + verts.extend([arc1[-1] * ctrl_factor, arc2[0] * ctrl_factor, arc2[0]]) |
131 | 131 | codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4]) |
132 | 132 |
|
133 | | - # Arc2 points |
134 | | - for pt in arc2_points[1:]: |
| 133 | + for pt in arc2[1:]: |
135 | 134 | verts.append(pt) |
136 | 135 | codes.append(Path.LINETO) |
137 | 136 |
|
138 | | - # Bezier curve from end of arc2 back to start of arc1 |
139 | | - ctrl3 = arc2_points[-1] * ctrl_factor |
140 | | - ctrl4 = arc1_points[0] * ctrl_factor |
141 | | - verts.extend([ctrl3, ctrl4, arc1_points[0]]) |
| 137 | + verts.extend([arc2[-1] * ctrl_factor, arc1[0] * ctrl_factor, arc1[0]]) |
142 | 138 | codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4]) |
143 | 139 |
|
144 | | - path = Path(verts, codes) |
145 | | - patch = mpatches.PathPatch(path, facecolor=color, edgecolor="none", alpha=alpha) |
| 140 | + # Scale alpha and linewidth by flow magnitude for clear visual hierarchy |
| 141 | + flow_ratio = flow / flow_matrix.max() |
| 142 | + alpha = 0.15 + 0.65 * flow_ratio**0.7 |
| 143 | + lw = 0.3 + 1.2 * flow_ratio |
| 144 | + patch = mpatches.PathPatch(Path(verts, codes), facecolor=color, edgecolor=color, linewidth=lw, alpha=alpha) |
146 | 145 | ax.add_patch(patch) |
147 | 146 |
|
| 147 | +# Annotate the top 3 flows to create a clear data story |
| 148 | +top_flows = sorted( |
| 149 | + [(i, j, flow_matrix[i, j]) for i in range(n) for j in range(n) if i != j and flow_matrix[i, j] > 0], |
| 150 | + key=lambda f: f[2], |
| 151 | + reverse=True, |
| 152 | +)[:3] |
| 153 | + |
| 154 | +ann_positions = [(-0.55, -1.28), (0.55, 1.20), (-0.95, 0.60)] |
| 155 | +for rank, (i, j, flow) in enumerate(top_flows): |
| 156 | + ax_x, ax_y = ann_positions[rank] |
| 157 | + label = f"{entities[i]} → {entities[j]}: {flow}M" |
| 158 | + fs = 14 if rank == 0 else 12 |
| 159 | + ax.annotate( |
| 160 | + label, |
| 161 | + xy=(ax_x, ax_y), |
| 162 | + fontsize=fs, |
| 163 | + fontweight="bold" if rank == 0 else "medium", |
| 164 | + ha="center", |
| 165 | + va="center", |
| 166 | + color="#333333", |
| 167 | + bbox={ |
| 168 | + "boxstyle": "round,pad=0.3", |
| 169 | + "facecolor": "white", |
| 170 | + "edgecolor": colors[i], |
| 171 | + "alpha": 0.92, |
| 172 | + "linewidth": 1.5, |
| 173 | + }, |
| 174 | + ) |
148 | 175 |
|
149 | | -# Track position within each entity arc for placing chords |
150 | | -arc_positions = {} |
151 | | -for i in range(n): |
152 | | - arc_positions[i] = {"out": start_angles[i], "in": start_angles[i]} |
153 | | - |
154 | | -# Calculate the angular span each flow unit represents for each entity |
155 | | -unit_angles = arc_spans / totals |
156 | | - |
157 | | -# Draw chords for each flow |
158 | | -for i in range(n): |
159 | | - for j in range(n): |
160 | | - if i != j and flow_matrix[i, j] > 0: |
161 | | - flow = flow_matrix[i, j] |
162 | | - |
163 | | - # Calculate chord width at source (outgoing from entity i) |
164 | | - source_span = flow * unit_angles[i] |
165 | | - source_start = arc_positions[i]["out"] - source_span |
166 | | - source_end = arc_positions[i]["out"] |
167 | | - arc_positions[i]["out"] = source_start |
168 | | - |
169 | | - # Calculate chord width at target (incoming to entity j) |
170 | | - target_span = flow * unit_angles[j] |
171 | | - target_start = arc_positions[j]["in"] - target_span |
172 | | - target_end = arc_positions[j]["in"] |
173 | | - arc_positions[j]["in"] = target_start |
174 | | - |
175 | | - # Draw the chord (use source color) |
176 | | - draw_chord(ax, source_start, source_end, target_start, target_end, colors[i]) |
177 | | - |
178 | | -# Title |
| 176 | +# Title and subtitle |
179 | 177 | ax.set_title( |
180 | | - "Continental Migration Flows · chord-basic · matplotlib · pyplots.ai", fontsize=24, fontweight="bold", pad=20 |
| 178 | + "Continental Migration Flows · chord-basic · matplotlib · pyplots.ai", |
| 179 | + fontsize=24, |
| 180 | + fontweight="medium", |
| 181 | + pad=40, |
| 182 | + color="#333333", |
| 183 | +) |
| 184 | +ax.text( |
| 185 | + 0, |
| 186 | + 1.38, |
| 187 | + "Asia–Europe corridor dominates global flows", |
| 188 | + fontsize=16, |
| 189 | + ha="center", |
| 190 | + va="center", |
| 191 | + color="#666666", |
| 192 | + fontstyle="italic", |
181 | 193 | ) |
182 | 194 |
|
183 | 195 | plt.tight_layout() |
184 | | -plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
| 196 | +plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="#FAFAFA") |
0 commit comments