Skip to content

Commit 811bc70

Browse files
update(chord-basic): matplotlib — comprehensive review (#5212)
## Summary Updated **matplotlib** implementation for **chord-basic**. **Changes:** Comprehensive review — code quality, data choice, visual design, spec compliance, library feature usage. ## Test Plan - [x] Preview images uploaded to GCS staging - [x] Implementation file passes ruff format/check - [x] Metadata YAML updated with current versions - [ ] Automated review triggered --- Generated with [Claude Code](https://claude.com/claude-code) `/update` command --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 1f99646 commit 811bc70

File tree

3 files changed

+337
-122
lines changed

3 files changed

+337
-122
lines changed
Lines changed: 124 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" pyplots.ai
22
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
55
"""
66

77
import matplotlib.patches as mpatches
@@ -11,11 +11,9 @@
1111

1212

1313
# Data: Migration flows between continents (in millions)
14-
np.random.seed(42)
1514
entities = ["Africa", "Asia", "Europe", "N. America", "S. America", "Oceania"]
1615
n = len(entities)
1716

18-
# Flow matrix (row=source, col=target)
1917
flow_matrix = np.array(
2018
[
2119
[0, 12, 8, 5, 2, 1], # From Africa
@@ -27,158 +25,172 @@
2725
]
2826
)
2927

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"]
3230

33-
# Calculate totals for each entity (sum of outgoing + incoming)
31+
# Calculate entity totals and arc geometry
3432
totals = flow_matrix.sum(axis=1) + flow_matrix.sum(axis=0)
3533
total_flow = totals.sum()
34+
gap_deg = 3
35+
available_deg = 360 - gap_deg * n
36+
arc_spans = (totals / total_flow) * available_deg
3637

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)
4639
start_angles = np.zeros(n)
47-
current_angle = 90 # Start from top
40+
angle = 90
4841
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")
5651
ax.axis("off")
5752

58-
# Draw entity arcs on the outer ring
5953
radius = 1.0
60-
arc_width = 0.08
54+
arc_width = 0.09
55+
inner_r = radius - arc_width
6156

57+
# Draw outer arcs
6258
for i in range(n):
6359
theta1 = start_angles[i] - arc_spans[i]
6460
theta2 = start_angles[i]
65-
66-
# Draw outer arc as a wedge
6761
wedge = mpatches.Wedge(
6862
(0, 0), radius, theta1, theta2, width=arc_width, facecolor=colors[i], edgecolor="white", linewidth=2
6963
)
7064
ax.add_patch(wedge)
7165

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])
7776

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
8880

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])
9084

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
9197

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
95102

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))
99104

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])
102107

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
106111

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)
110115

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)])
113118

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)])
117121

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]
121125

122-
# Arc1 points
123-
for pt in arc1_points[1:]:
126+
for pt in arc1[1:]:
124127
verts.append(pt)
125128
codes.append(Path.LINETO)
126129

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]])
131131
codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4])
132132

133-
# Arc2 points
134-
for pt in arc2_points[1:]:
133+
for pt in arc2[1:]:
135134
verts.append(pt)
136135
codes.append(Path.LINETO)
137136

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]])
142138
codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4])
143139

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)
146145
ax.add_patch(patch)
147146

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+
)
148175

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
179177
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",
181193
)
182194

183195
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

Comments
 (0)