Skip to content
236 changes: 124 additions & 112 deletions plots/chord-basic/implementations/matplotlib.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
""" pyplots.ai
chord-basic: Basic Chord Diagram
Library: matplotlib 3.10.8 | Python 3.13.11
Quality: 98/100 | Created: 2025-12-14
Library: matplotlib 3.10.8 | Python 3.14
Quality: 90/100 | Created: 2026-04-06
"""

import matplotlib.patches as mpatches
Expand All @@ -11,11 +11,9 @@


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

# Flow matrix (row=source, col=target)
flow_matrix = np.array(
[
[0, 12, 8, 5, 2, 1], # From Africa
Expand All @@ -27,158 +25,172 @@
]
)

# Colors for each entity (colorblind-safe palette)
colors = ["#306998", "#FFD43B", "#4ECDC4", "#FF6B6B", "#95E1A3", "#DDA0DD"]
# Colorblind-safe palette starting with Python Blue
colors = ["#306998", "#E69F00", "#009E73", "#D55E00", "#56B4E9", "#CC79A7"]

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

# Gap between entity arcs (in degrees)
gap = 3
total_gap = gap * n
available_degrees = 360 - total_gap

# Calculate arc spans for each entity
arc_spans = (totals / total_flow) * available_degrees

# Calculate start angles for each entity arc
# Start angles (clockwise from top)
start_angles = np.zeros(n)
current_angle = 90 # Start from top
angle = 90
for i in range(n):
start_angles[i] = current_angle
current_angle -= arc_spans[i] + gap

# Create figure
fig, ax = plt.subplots(figsize=(16, 9), subplot_kw={"aspect": "equal"})
ax.set_xlim(-1.5, 1.5)
ax.set_ylim(-1.3, 1.3)
start_angles[i] = angle
angle -= arc_spans[i] + gap_deg

# Plot — square canvas for circular chart
fig, ax = plt.subplots(figsize=(12, 12), subplot_kw={"aspect": "equal"})
fig.set_facecolor("#FAFAFA")
ax.set_xlim(-1.45, 1.45)
ax.set_ylim(-1.45, 1.45)
ax.set_facecolor("#FAFAFA")
ax.axis("off")

# Draw entity arcs on the outer ring
radius = 1.0
arc_width = 0.08
arc_width = 0.09
inner_r = radius - arc_width

# Draw outer arcs
for i in range(n):
theta1 = start_angles[i] - arc_spans[i]
theta2 = start_angles[i]

# Draw outer arc as a wedge
wedge = mpatches.Wedge(
(0, 0), radius, theta1, theta2, width=arc_width, facecolor=colors[i], edgecolor="white", linewidth=2
)
ax.add_patch(wedge)

# Add entity label
mid_angle = (theta1 + theta2) / 2
label_radius = radius + 0.12
label_x = label_radius * np.cos(np.radians(mid_angle))
label_y = label_radius * np.sin(np.radians(mid_angle))
# Label placement
mid = np.radians((theta1 + theta2) / 2)
lx, ly = (radius + 0.12) * np.cos(mid), (radius + 0.12) * np.sin(mid)
mid_deg = np.degrees(mid) % 360
ha = (
"center"
if mid_deg < 15 or mid_deg > 345 or 165 < mid_deg < 195
else ("right" if 90 < mid_deg < 270 else "left")
)
ax.text(lx, ly, entities[i], fontsize=20, fontweight="bold", ha=ha, va="center", color=colors[i])

# Rotate text to align with arc
rotation = mid_angle
if mid_angle > 90 or mid_angle < -90:
rotation = mid_angle + 180
if 90 < mid_angle < 270 or -270 < mid_angle < -90:
ha = "right"
else:
ha = "left"
if abs(mid_angle) < 10 or abs(mid_angle - 180) < 10 or abs(mid_angle + 180) < 10:
ha = "center"
# Track angular position within each arc for chord placement
arc_cursors = start_angles.copy()
Comment on lines +77 to +78
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arc_cursors is assigned but never used. Since plots/ is excluded from Ruff checks, this won’t be caught automatically but still adds dead code and confusion. Remove it, or use it consistently for chord placement if that was the intent.

Suggested change
# Track angular position within each arc for chord placement
arc_cursors = start_angles.copy()
# Angular span per unit of flow for chord placement

Copilot uses AI. Check for mistakes.
unit_angles = arc_spans / totals

ax.text(label_x, label_y, entities[i], fontsize=18, fontweight="bold", ha=ha, va="center", color=colors[i])
# Sort flows by magnitude (draw largest last for visual hierarchy)
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]
flows.sort(key=lambda f: f[2])
Comment on lines +81 to +83
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flows is computed and sorted but never used (the code renders based on chord_params instead). Remove flows and/or update the surrounding comment so it matches the actual draw-order logic.

Copilot uses AI. Check for mistakes.

# Pre-compute chord positions to avoid cursor interference from draw order
min_chord_deg = 1.5 # minimum angular span for visibility
chord_params = []
pos_cursors = start_angles.copy()
for i in range(n):
for j in range(n):
if i != j and flow_matrix[i, j] > 0:
flow = flow_matrix[i, j]
src_span = max(flow * unit_angles[i], min_chord_deg)
src_end = pos_cursors[i]
src_start = src_end - src_span
pos_cursors[i] = src_start

def draw_chord(ax, start1, end1, start2, end2, color, alpha=0.65):
"""Draw a chord between two arcs using Bezier curves."""
inner_radius = radius - arc_width
tgt_span = max(flow * unit_angles[j], min_chord_deg)
tgt_end = pos_cursors[j]
tgt_start = tgt_end - tgt_span
pos_cursors[j] = tgt_start

# Convert angles to radians
s1, e1 = np.radians(start1), np.radians(end1)
s2, e2 = np.radians(start2), np.radians(end2)
chord_params.append((src_start, src_end, tgt_start, tgt_end, colors[i], flow))

# Create path points
n_arc_points = 20
# Sort by flow magnitude so largest chords render on top
chord_params.sort(key=lambda c: c[5])

# First arc (source)
arc1_angles = np.linspace(s1, e1, n_arc_points)
arc1_points = np.column_stack([inner_radius * np.cos(arc1_angles), inner_radius * np.sin(arc1_angles)])
# Draw chords using cubic Bezier paths
n_arc_pts = 30
ctrl_factor = 0.25

# Second arc (target)
arc2_angles = np.linspace(s2, e2, n_arc_points)
arc2_points = np.column_stack([inner_radius * np.cos(arc2_angles), inner_radius * np.sin(arc2_angles)])
for src_start, src_end, tgt_start, tgt_end, color, flow in chord_params:
s1, e1 = np.radians(src_start), np.radians(src_end)
s2, e2 = np.radians(tgt_start), np.radians(tgt_end)

# Control points for Bezier curves (through center with some offset)
ctrl_factor = 0.3
arc1_t = np.linspace(s1, e1, n_arc_pts)
arc1 = np.column_stack([inner_r * np.cos(arc1_t), inner_r * np.sin(arc1_t)])

# Build path: arc1 -> bezier to arc2 -> arc2 -> bezier back to arc1
verts = []
codes = []
arc2_t = np.linspace(s2, e2, n_arc_pts)
arc2 = np.column_stack([inner_r * np.cos(arc2_t), inner_r * np.sin(arc2_t)])

# Start at first point of arc1
verts.append(arc1_points[0])
codes.append(Path.MOVETO)
# Build closed path: arc1 → bezier → arc2 → bezier → close
verts = [arc1[0]]
codes = [Path.MOVETO]

# Arc1 points
for pt in arc1_points[1:]:
for pt in arc1[1:]:
verts.append(pt)
codes.append(Path.LINETO)

# Bezier curve from end of arc1 to start of arc2
ctrl1 = arc1_points[-1] * ctrl_factor
ctrl2 = arc2_points[0] * ctrl_factor
verts.extend([ctrl1, ctrl2, arc2_points[0]])
verts.extend([arc1[-1] * ctrl_factor, arc2[0] * ctrl_factor, arc2[0]])
codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4])

# Arc2 points
for pt in arc2_points[1:]:
for pt in arc2[1:]:
verts.append(pt)
codes.append(Path.LINETO)

# Bezier curve from end of arc2 back to start of arc1
ctrl3 = arc2_points[-1] * ctrl_factor
ctrl4 = arc1_points[0] * ctrl_factor
verts.extend([ctrl3, ctrl4, arc1_points[0]])
verts.extend([arc2[-1] * ctrl_factor, arc1[0] * ctrl_factor, arc1[0]])
codes.extend([Path.CURVE4, Path.CURVE4, Path.CURVE4])

path = Path(verts, codes)
patch = mpatches.PathPatch(path, facecolor=color, edgecolor="none", alpha=alpha)
# Scale alpha and linewidth by flow magnitude for clear visual hierarchy
flow_ratio = flow / flow_matrix.max()
alpha = 0.15 + 0.65 * flow_ratio**0.7
lw = 0.3 + 1.2 * flow_ratio
patch = mpatches.PathPatch(Path(verts, codes), facecolor=color, edgecolor=color, linewidth=lw, alpha=alpha)
ax.add_patch(patch)

# Annotate the top 3 flows to create a clear data story
top_flows = sorted(
[(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],
key=lambda f: f[2],
reverse=True,
)[:3]

ann_positions = [(-0.55, -1.28), (0.55, 1.20), (-0.95, 0.60)]
for rank, (i, j, flow) in enumerate(top_flows):
ax_x, ax_y = ann_positions[rank]
label = f"{entities[i]} → {entities[j]}: {flow}M"
fs = 14 if rank == 0 else 12
ax.annotate(
label,
xy=(ax_x, ax_y),
fontsize=fs,
fontweight="bold" if rank == 0 else "medium",
ha="center",
va="center",
color="#333333",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": "white",
"edgecolor": colors[i],
"alpha": 0.92,
"linewidth": 1.5,
},
)

# Track position within each entity arc for placing chords
arc_positions = {}
for i in range(n):
arc_positions[i] = {"out": start_angles[i], "in": start_angles[i]}

# Calculate the angular span each flow unit represents for each entity
unit_angles = arc_spans / totals

# Draw chords for each flow
for i in range(n):
for j in range(n):
if i != j and flow_matrix[i, j] > 0:
flow = flow_matrix[i, j]

# Calculate chord width at source (outgoing from entity i)
source_span = flow * unit_angles[i]
source_start = arc_positions[i]["out"] - source_span
source_end = arc_positions[i]["out"]
arc_positions[i]["out"] = source_start

# Calculate chord width at target (incoming to entity j)
target_span = flow * unit_angles[j]
target_start = arc_positions[j]["in"] - target_span
target_end = arc_positions[j]["in"]
arc_positions[j]["in"] = target_start

# Draw the chord (use source color)
draw_chord(ax, source_start, source_end, target_start, target_end, colors[i])

# Title
# Title and subtitle
ax.set_title(
"Continental Migration Flows · chord-basic · matplotlib · pyplots.ai", fontsize=24, fontweight="bold", pad=20
"Continental Migration Flows · chord-basic · matplotlib · pyplots.ai",
fontsize=24,
fontweight="medium",
pad=40,
color="#333333",
)
ax.text(
0,
1.38,
"Asia–Europe corridor dominates global flows",
fontsize=16,
ha="center",
va="center",
color="#666666",
fontstyle="italic",
)

plt.tight_layout()
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="#FAFAFA")
Loading
Loading