Skip to content

Commit 249c812

Browse files
update(bubble-packed): matplotlib — comprehensive quality review (#4358)
## Summary Updated **matplotlib** implementation for **bubble-packed**. **Changes:** Comprehensive quality review ### Changes - Replaced random seed with deterministic data - Added `matplotlib.collections` for PatchCollection (distinctive library feature) - Improved circle packing algorithm - Updated header and metadata ## 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 c60a009 commit 249c812

3 files changed

Lines changed: 338 additions & 193 deletions

File tree

plots/bubble-packed/implementations/matplotlib.py

Lines changed: 181 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
""" pyplots.ai
22
bubble-packed: Basic Packed Bubble Chart
3-
Library: matplotlib 3.10.8 | Python 3.13.11
4-
Quality: 92/100 | Created: 2025-12-23
3+
Library: matplotlib 3.10.8 | Python 3.14.3
4+
Quality: 87/100 | Updated: 2026-02-23
55
"""
66

7+
import matplotlib.collections as mcoll
78
import matplotlib.patches as mpatches
9+
import matplotlib.patheffects as pe
810
import matplotlib.pyplot as plt
911
import numpy as np
1012

1113

1214
# Data - Department budget allocation (in thousands)
13-
np.random.seed(42)
1415
labels = [
1516
"Engineering",
1617
"Marketing",
@@ -28,136 +29,235 @@
2829
"Security",
2930
"QA",
3031
]
31-
values = [850, 420, 680, 320, 180, 290, 750, 210, 150, 380, 240, 550, 460, 170, 195]
32-
33-
# Colors by group (Python Blue primary, Yellow secondary, others colorblind-safe)
34-
colors = [
35-
"#306998", # Engineering - Blue (Tech)
36-
"#FFD43B", # Marketing - Yellow (Business)
37-
"#306998", # Sales - Blue (Revenue)
38-
"#4A90A4", # Operations - Teal (Support)
39-
"#4A90A4", # HR - Teal (Support)
40-
"#4A90A4", # Finance - Teal (Support)
41-
"#FFD43B", # R&D - Yellow (Innovation)
42-
"#4A90A4", # Customer Support - Teal (Support)
43-
"#7B9E89", # Legal - Sage (Compliance)
44-
"#306998", # IT - Blue (Tech)
45-
"#FFD43B", # Design - Yellow (Creative)
46-
"#306998", # Product - Blue (Tech)
47-
"#FFD43B", # Data Science - Yellow (Analytics)
48-
"#7B9E89", # Security - Sage (Compliance)
49-
"#7B9E89", # QA - Sage (Quality)
50-
]
32+
values = [950, 420, 680, 310, 160, 280, 820, 200, 130, 370, 230, 580, 470, 145, 175]
33+
34+
# Group assignments - realistic organizational structure
35+
group_map = {
36+
"Engineering": "Engineering",
37+
"IT": "Engineering",
38+
"Data Science": "Engineering",
39+
"R&D": "Engineering",
40+
"Marketing": "Business",
41+
"Sales": "Business",
42+
"Product": "Business",
43+
"Design": "Business",
44+
"Operations": "Operations",
45+
"HR": "Operations",
46+
"Finance": "Operations",
47+
"Customer Support": "Operations",
48+
"Legal": "Compliance",
49+
"Security": "Compliance",
50+
"QA": "Compliance",
51+
}
52+
53+
# Colorblind-safe palette with high hue separation
54+
group_colors = {"Engineering": "#306998", "Business": "#E8C33A", "Operations": "#D4654A", "Compliance": "#8B6DB0"}
55+
colors = [group_colors[group_map[label]] for label in labels]
5156

5257
# Scale values to radius (sqrt for area-proportional sizing)
53-
min_radius = 0.35
54-
max_radius = 1.9
55-
values_array = np.array(values)
58+
min_radius = 0.30
59+
max_radius = 2.0
60+
values_array = np.array(values, dtype=float)
5661
radii = min_radius + (max_radius - min_radius) * np.sqrt(
5762
(values_array - values_array.min()) / (values_array.max() - values_array.min())
5863
)
5964

60-
# Circle packing using physics simulation
61-
n = len(labels)
62-
63-
# Initial positions in grid
64-
grid_size = int(np.ceil(np.sqrt(n)))
65-
positions = np.zeros((n, 2))
66-
for i in range(n):
67-
positions[i] = [(i % grid_size) * 4 - grid_size * 2, (i // grid_size) * 4 - grid_size * 2]
68-
6965
# Sort by size (largest first) for better packing
66+
n = len(labels)
7067
order = np.argsort(-radii)
71-
positions = positions[order]
7268
radii_sorted = radii[order]
7369
labels_sorted = [labels[i] for i in order]
7470
values_sorted = [values[i] for i in order]
7571
colors_sorted = [colors[i] for i in order]
72+
groups_sorted = [group_map[labels[i]] for i in order]
7673

77-
# Physics simulation for packing
78-
for iteration in range(350):
79-
# Pull toward center with decreasing strength
80-
pull_strength = 0.06 * (1 - iteration / 400)
74+
# Assign group IDs for clustering
75+
unique_groups = list(group_colors.keys())
76+
group_ids = np.array([unique_groups.index(g) for g in groups_sorted])
77+
78+
# Initial positions in spiral pattern for tighter convergence
79+
angles = np.linspace(0, 4 * np.pi, n)
80+
spiral_r = np.linspace(0, 3, n)
81+
positions = np.column_stack([spiral_r * np.cos(angles), spiral_r * np.sin(angles)])
82+
83+
# Physics simulation with group-aware clustering
84+
for iteration in range(500):
85+
progress = iteration / 500
86+
pull_strength = 0.06 * (1 - progress * 0.8)
87+
group_pull = 0.04 * (1 - progress * 0.5)
88+
89+
# Compute group centers of mass
90+
group_centers = {}
91+
for gid in range(len(unique_groups)):
92+
mask = group_ids == gid
93+
if np.any(mask):
94+
group_centers[gid] = positions[mask].mean(axis=0)
95+
96+
# Pull toward center + pull toward own group center
8197
for i in range(n):
82-
dist = np.sqrt(positions[i, 0] ** 2 + positions[i, 1] ** 2)
98+
dist = np.linalg.norm(positions[i])
8399
if dist > 0.01:
84100
positions[i] -= pull_strength * positions[i] / dist
85101

102+
gc = group_centers[group_ids[i]]
103+
to_group = gc - positions[i]
104+
gd = np.linalg.norm(to_group)
105+
if gd > 0.01:
106+
positions[i] += group_pull * to_group / gd
107+
86108
# Push apart overlapping circles
87109
for i in range(n):
88110
for j in range(i + 1, n):
89-
dx = positions[j, 0] - positions[i, 0]
90-
dy = positions[j, 1] - positions[i, 1]
91-
dist = np.sqrt(dx**2 + dy**2)
92-
min_dist = radii_sorted[i] + radii_sorted[j] + 0.05 # Small gap between circles
111+
delta = positions[j] - positions[i]
112+
dist = np.linalg.norm(delta)
113+
same_group = group_ids[i] == group_ids[j]
114+
gap = 0.04 if same_group else 0.15
115+
min_dist = radii_sorted[i] + radii_sorted[j] + gap
93116

94117
if dist < min_dist and dist > 0.001:
95118
overlap = (min_dist - dist) / 2
96-
dx_norm = dx / dist
97-
dy_norm = dy / dist
98-
positions[i, 0] -= overlap * dx_norm
99-
positions[i, 1] -= overlap * dy_norm
100-
positions[j, 0] += overlap * dx_norm
101-
positions[j, 1] += overlap * dy_norm
102-
103-
# Create plot (4800x2700 px at 300 dpi)
119+
direction = delta / dist
120+
positions[i] -= overlap * direction
121+
positions[j] += overlap * direction
122+
123+
# Center the layout: shift all positions so the bounding box is centered at origin
124+
bbox_min = positions.min(axis=0) - radii_sorted.max()
125+
bbox_max = positions.max(axis=0) + radii_sorted.max()
126+
positions -= (bbox_min + bbox_max) / 2
127+
128+
# Plot (4800x2700 px at 300 dpi)
104129
fig, ax = plt.subplots(figsize=(16, 9))
105130

106-
# Draw circles
131+
# Draw circles using PatchCollection for efficient rendering
132+
circles = []
133+
face_colors = []
107134
for i in range(n):
108-
circle = mpatches.Circle(
109-
(positions[i, 0], positions[i, 1]),
110-
radii_sorted[i],
111-
facecolor=colors_sorted[i],
112-
edgecolor="white",
113-
linewidth=2.5,
114-
alpha=0.88,
115-
)
116-
ax.add_patch(circle)
135+
circle = mpatches.Circle((positions[i, 0], positions[i, 1]), radii_sorted[i])
136+
circles.append(circle)
137+
face_colors.append(colors_sorted[i])
117138

118-
# Add labels inside larger circles
119-
label_len = len(labels_sorted[i])
120-
min_radius_for_label = 0.55 + label_len * 0.025
121-
if radii_sorted[i] > min_radius_for_label:
139+
collection = mcoll.PatchCollection(
140+
circles, facecolors=face_colors, edgecolors="white", linewidths=2.5, alpha=0.90, zorder=2
141+
)
142+
ax.add_collection(collection)
143+
144+
# Add labels inside circles that are large enough, external labels for small ones
145+
small_circles = []
146+
for i in range(n):
147+
label_chars = len(labels_sorted[i])
148+
min_r_for_label = 0.48 + label_chars * 0.018
149+
if radii_sorted[i] > min_r_for_label:
122150
font_scale = min(1.0, radii_sorted[i] / 1.4)
123-
label_fontsize = max(9, int(15 * font_scale))
124-
value_fontsize = max(8, int(13 * font_scale))
151+
label_fontsize = max(12, int(15 * font_scale))
152+
value_fontsize = max(12, int(13 * font_scale))
153+
154+
# Determine text color based on background luminance (WCAG relative luminance)
155+
bg_color = colors_sorted[i]
156+
rgb = [int(bg_color[j : j + 2], 16) / 255 for j in (1, 3, 5)]
157+
luminance = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
158+
text_color = "#1a1a2e" if luminance > 0.45 else "white"
159+
text_outline = (
160+
pe.withStroke(linewidth=3, foreground="#00000033")
161+
if luminance <= 0.45
162+
else pe.withStroke(linewidth=3, foreground="#ffffff33")
163+
)
164+
165+
# Wrap long labels for smaller circles
166+
display_label = labels_sorted[i]
167+
is_wrapped = False
168+
if " " in display_label and radii_sorted[i] < 1.0:
169+
display_label = display_label.replace(" ", "\n")
170+
is_wrapped = True
171+
172+
# Adjust vertical offsets for wrapped vs single-line labels
173+
label_y_offset = 0.05 if is_wrapped else 0.12
174+
value_y_offset = -0.35 if is_wrapped else -0.22
175+
125176
ax.text(
126177
positions[i, 0],
127-
positions[i, 1] + radii_sorted[i] * 0.1,
128-
labels_sorted[i],
178+
positions[i, 1] + radii_sorted[i] * label_y_offset,
179+
display_label,
129180
ha="center",
130181
va="center",
131182
fontsize=label_fontsize,
132183
fontweight="bold",
133-
color="white",
184+
color=text_color,
185+
path_effects=[text_outline],
186+
zorder=3,
134187
)
135188
ax.text(
136189
positions[i, 0],
137-
positions[i, 1] - radii_sorted[i] * 0.22,
190+
positions[i, 1] + radii_sorted[i] * value_y_offset,
138191
f"${values_sorted[i]}K",
139192
ha="center",
140193
va="center",
141194
fontsize=value_fontsize,
142-
color="white",
143-
alpha=0.95,
195+
color=text_color,
196+
alpha=0.85,
197+
path_effects=[text_outline],
198+
zorder=3,
144199
)
200+
else:
201+
small_circles.append(i)
145202

146-
# Set axis limits with padding
203+
# External labels with leader lines for small circles
204+
for i in small_circles:
205+
cx, cy = positions[i, 0], positions[i, 1]
206+
r = radii_sorted[i]
207+
208+
# Find direction away from center for label placement
209+
angle = np.arctan2(cy, cx)
210+
offset_dist = r + 0.6
211+
lx = cx + offset_dist * np.cos(angle)
212+
ly = cy + offset_dist * np.sin(angle)
213+
214+
ax.annotate(
215+
f"{labels_sorted[i]}\n${values_sorted[i]}K",
216+
xy=(cx, cy),
217+
xytext=(lx, ly),
218+
fontsize=12,
219+
fontweight="bold",
220+
color="#333333",
221+
ha="center",
222+
va="center",
223+
arrowprops={"arrowstyle": "-", "color": "#666666", "lw": 1.2, "shrinkA": 0, "shrinkB": 2},
224+
zorder=4,
225+
)
226+
227+
# Axis limits with padding
147228
all_x = positions[:, 0]
148229
all_y = positions[:, 1]
149230
max_r = radii_sorted.max()
150-
padding = 0.6
231+
padding = 0.9
151232
ax.set_xlim(all_x.min() - max_r - padding, all_x.max() + max_r + padding)
152233
ax.set_ylim(all_y.min() - max_r - padding, all_y.max() + max_r + padding)
153234
ax.set_aspect("equal")
154-
155-
# Remove axes for clean visualization
156235
ax.axis("off")
157236

158-
# Title
237+
# Title with total budget subtitle for context
238+
total_budget = sum(values)
159239
ax.set_title(
160-
"Department Budget Allocation · bubble-packed · matplotlib · pyplots.ai", fontsize=24, fontweight="bold", pad=20
240+
f"Department Budget Allocation (${total_budget / 1000:.1f}M Total)\nbubble-packed · matplotlib · pyplots.ai",
241+
fontsize=24,
242+
fontweight="bold",
243+
pad=20,
244+
)
245+
246+
# Legend for group colors
247+
legend_handles = [
248+
mpatches.Patch(facecolor=color, edgecolor="white", linewidth=1.5, label=group)
249+
for group, color in group_colors.items()
250+
]
251+
ax.legend(
252+
handles=legend_handles,
253+
loc="lower right",
254+
fontsize=16,
255+
framealpha=0.9,
256+
edgecolor="#cccccc",
257+
fancybox=True,
258+
borderpad=0.8,
259+
handlelength=1.5,
260+
handleheight=1.2,
161261
)
162262

163263
plt.tight_layout()

0 commit comments

Comments
 (0)