Skip to content

Commit da76d19

Browse files
update(bubble-packed): plotnine — comprehensive quality review (#4363)
## Summary Updated **plotnine** implementation for **bubble-packed**. **Changes:** Comprehensive quality review ### Changes - Switched from element_blank to element_rect for background - Added guides import for legend control - Renamed data to departments for clarity - Removed random seed (deterministic data) ## 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 249c812 commit da76d19

2 files changed

Lines changed: 286 additions & 215 deletions

File tree

plots/bubble-packed/implementations/plotnine.py

Lines changed: 133 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
""" pyplots.ai
22
bubble-packed: Basic Packed Bubble Chart
3-
Library: plotnine 0.15.2 | Python 3.13.11
4-
Quality: 90/100 | Created: 2025-12-23
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 90/100 | Updated: 2026-02-23
55
"""
66

77
import numpy as np
88
import pandas as pd
99
from plotnine import (
1010
aes,
1111
coord_fixed,
12-
element_blank,
12+
element_rect,
1313
element_text,
1414
geom_polygon,
1515
geom_text,
1616
ggplot,
17+
guides,
1718
labs,
19+
scale_alpha_manual,
1820
scale_fill_manual,
1921
theme,
2022
theme_void,
2123
)
2224

2325

2426
# Data - department budgets (in millions)
25-
np.random.seed(42)
26-
data = {
27+
departments = {
2728
"label": [
2829
"Engineering",
2930
"Marketing",
@@ -34,7 +35,7 @@
3435
"R&D",
3536
"IT Support",
3637
"Legal",
37-
"Customer Service",
38+
"Customer Svc",
3839
"Design",
3940
"Logistics",
4041
"Quality",
@@ -61,142 +62,177 @@
6162
],
6263
}
6364

64-
df = pd.DataFrame(data)
65+
df = pd.DataFrame(departments)
6566

6667
# Scale values to radii (area-based scaling for accurate perception)
6768
max_radius = 1.0
68-
min_radius = 0.3
69+
min_radius = 0.25
6970
df["radius"] = min_radius + (max_radius - min_radius) * np.sqrt(df["value"] / df["value"].max())
7071

71-
# Circle packing using force simulation (inline, KISS style)
72+
# Circle packing - greedy placement with vectorized collision detection
7273
n = len(df)
7374
radii = df["radius"].values
74-
75-
# Sort by size (largest first) for better packing
7675
idx = np.argsort(-radii)
7776
sorted_radii = radii[idx]
77+
gap = 0.03
7878

79-
# Initialize positions
8079
x = np.zeros(n)
8180
y = np.zeros(n)
81+
angles_sweep = np.linspace(0, 2 * np.pi, 72, endpoint=False)
8282

83-
# Place circles using greedy algorithm
8483
for i in range(1, n):
8584
best_dist = float("inf")
8685
best_x, best_y = 0.0, 0.0
87-
88-
for angle in np.linspace(0, 2 * np.pi, 36):
89-
for ref in range(i):
90-
# Try placing next to reference circle
91-
test_x = x[ref] + (sorted_radii[ref] + sorted_radii[i] + 0.05) * np.cos(angle)
92-
test_y = y[ref] + (sorted_radii[ref] + sorted_radii[i] + 0.05) * np.sin(angle)
93-
94-
# Check for collisions
95-
valid = True
96-
for j in range(i):
97-
dist = np.sqrt((test_x - x[j]) ** 2 + (test_y - y[j]) ** 2)
98-
if dist < sorted_radii[i] + sorted_radii[j] + 0.03:
99-
valid = False
100-
break
101-
102-
if valid:
103-
center_dist = np.sqrt(test_x**2 + test_y**2)
104-
if center_dist < best_dist:
105-
best_dist = center_dist
106-
best_x, best_y = test_x, test_y
86+
target_r = sorted_radii[i]
87+
88+
for ref in range(i):
89+
place_r = sorted_radii[ref] + target_r + gap
90+
cx = x[ref] + place_r * np.cos(angles_sweep)
91+
cy = y[ref] + place_r * np.sin(angles_sweep)
92+
93+
# Vectorized collision check across all angles simultaneously
94+
dx_c = cx[:, np.newaxis] - x[:i][np.newaxis, :]
95+
dy_c = cy[:, np.newaxis] - y[:i][np.newaxis, :]
96+
dists_c = np.hypot(dx_c, dy_c)
97+
valid = np.all(dists_c >= target_r + sorted_radii[:i] + gap, axis=1)
98+
99+
center_dists = cx**2 + cy**2
100+
valid_dists = np.where(valid, center_dists, float("inf"))
101+
best_k = np.argmin(valid_dists)
102+
if valid_dists[best_k] < best_dist:
103+
best_dist = valid_dists[best_k]
104+
best_x, best_y = cx[best_k], cy[best_k]
107105

108106
x[i] = best_x
109107
y[i] = best_y
110108

111-
# Force simulation to tighten packing
112-
for _ in range(1000):
113-
# Move toward center
114-
x -= x * 0.001
115-
y -= y * 0.001
116-
117-
# Separate overlapping circles
118-
for i in range(n):
119-
for j in range(i + 1, n):
120-
dx = x[j] - x[i]
121-
dy = y[j] - y[i]
122-
dist = np.sqrt(dx * dx + dy * dy)
123-
min_dist = sorted_radii[i] + sorted_radii[j] + 0.03
124-
125-
if dist < min_dist and dist > 0.001:
126-
overlap = (min_dist - dist) / 2
127-
dx_norm = dx / dist
128-
dy_norm = dy / dist
129-
x[i] -= overlap * dx_norm * 0.5
130-
y[i] -= overlap * dy_norm * 0.5
131-
x[j] += overlap * dx_norm * 0.5
132-
y[j] += overlap * dy_norm * 0.5
109+
# Force simulation to tighten packing (vectorized with numpy)
110+
tri = np.triu(np.ones((n, n), dtype=bool), k=1)
111+
min_dists = sorted_radii[:, np.newaxis] + sorted_radii[np.newaxis, :] + gap
112+
113+
for _ in range(2000):
114+
x *= 0.997
115+
y *= 0.997
116+
117+
dx = x[:, np.newaxis] - x[np.newaxis, :]
118+
dy = y[:, np.newaxis] - y[np.newaxis, :]
119+
dists = np.hypot(dx, dy)
120+
121+
overlap = tri & (dists < min_dists) & (dists > 1e-3)
122+
if overlap.any():
123+
safe_dists = np.where(dists > 1e-3, dists, 1.0)
124+
push = ((min_dists - dists) / (2 * safe_dists)) * overlap
125+
corr_x = push * dx
126+
corr_y = push * dy
127+
x += corr_x.sum(axis=1) - corr_x.sum(axis=0)
128+
y += corr_y.sum(axis=1) - corr_y.sum(axis=0)
133129

134130
# Restore original order
135-
x_out = np.zeros(n)
136-
y_out = np.zeros(n)
131+
x_final = np.zeros(n)
132+
y_final = np.zeros(n)
137133
for i, orig_idx in enumerate(idx):
138-
x_out[orig_idx] = x[i]
139-
y_out[orig_idx] = y[i]
134+
x_final[orig_idx] = x[i]
135+
y_final[orig_idx] = y[i]
140136

141-
df["x"] = x_out
142-
df["y"] = y_out
137+
df["x"] = x_final
138+
df["y"] = y_final
143139

144-
# Create circle polygons for geom_polygon
140+
# Build circle polygons for geom_polygon
145141
circle_dfs = []
142+
angles = np.linspace(0, 2 * np.pi, 64)
146143
for i, row in df.iterrows():
147-
angles = np.linspace(0, 2 * np.pi, 64)
148144
cx = row["x"] + row["radius"] * np.cos(angles)
149145
cy = row["y"] + row["radius"] * np.sin(angles)
150-
circle_df = pd.DataFrame({"x": cx, "y": cy, "label": row["label"], "group": row["group"], "circle_id": i})
151-
circle_dfs.append(circle_df)
152-
146+
circle_dfs.append(pd.DataFrame({"x": cx, "y": cy, "label": row["label"], "group": row["group"], "circle_id": i}))
153147
circles_df = pd.concat(circle_dfs, ignore_index=True)
148+
circles_df["group"] = pd.Categorical(circles_df["group"], categories=["Tech", "Business", "Operations", "Support"])
154149

155-
# Color palette for groups - colorblind-safe (Okabe-Ito palette)
156-
group_colors = {
157-
"Tech": "#0072B2", # Blue
158-
"Business": "#E69F00", # Orange
159-
"Operations": "#009E73", # Bluish Green
160-
"Support": "#CC79A7", # Reddish Purple
161-
}
162-
163-
# Create label dataframe (centers) - show full labels for circles large enough
164-
labels_df = df[["x", "y", "label", "radius"]].copy()
165-
166-
# Show full label for large circles, abbreviated for medium, none for small
150+
# Labels - conditional sizing: full name for large, abbreviated for small
151+
labels_df = df.copy()
167152
labels_df["display_label"] = labels_df.apply(
168153
lambda row: (
169-
row["label"]
170-
if row["radius"] >= 0.85
171-
else (
172-
(row["label"][:8] if len(row["label"]) > 8 else row["label"])
173-
if row["radius"] >= 0.6
174-
else ((row["label"][:5] if len(row["label"]) > 5 else row["label"]) if row["radius"] >= 0.45 else "")
175-
)
154+
row["label"] if row["value"] >= 22 else (row["label"].split()[0] if row["value"] >= 10 else row["label"][:4])
176155
),
177156
axis=1,
178157
)
158+
labels_df["value_label"] = labels_df["value"].apply(lambda v: f"${v}M")
179159

180-
# Create plot
160+
# Alpha by group emphasis — Tech & Business slightly more prominent
161+
alpha_values = {"Tech": 0.90, "Business": 0.85, "Operations": 0.78, "Support": 0.75}
162+
163+
# Color palette - Okabe-Ito colorblind-safe
164+
group_colors = {"Tech": "#0072B2", "Business": "#E69F00", "Operations": "#009E73", "Support": "#CC79A7"}
165+
166+
# Compute group totals for subtitle
167+
group_totals = df.groupby("group")["value"].sum()
168+
subtitle_text = " \u00b7 ".join(f"{g}: \\${group_totals[g]}M" for g in ["Tech", "Business", "Operations", "Support"])
169+
170+
# Tight viewport bounds for optimal canvas utilization
171+
pad = 0.15
172+
x_lo = (df["x"] - df["radius"]).min() - pad
173+
x_hi = (df["x"] + df["radius"]).max() + pad
174+
y_lo = (df["y"] - df["radius"]).min() - pad
175+
y_hi = (df["y"] + df["radius"]).max() + pad
176+
half_span = max(x_hi - x_lo, y_hi - y_lo) / 2
177+
cx_mid, cy_mid = (x_lo + x_hi) / 2, (y_lo + y_hi) / 2
178+
179+
# Plot with layered grammar of graphics composition
181180
plot = (
182181
ggplot()
182+
# Layer 1: Circle fills with group-specific alpha
183183
+ geom_polygon(
184-
data=circles_df, mapping=aes(x="x", y="y", fill="group", group="circle_id"), color="white", size=0.5, alpha=0.85
184+
data=circles_df,
185+
mapping=aes(x="x", y="y", fill="group", group="circle_id", alpha="group"),
186+
color="white",
187+
size=0.8,
188+
)
189+
# Layer 2: Department name labels (bold, white)
190+
+ geom_text(
191+
data=labels_df[labels_df["value"] >= 10],
192+
mapping=aes(x="x", y="y", label="display_label"),
193+
size=12,
194+
color="white",
195+
fontweight="bold",
196+
nudge_y=0.10,
197+
)
198+
# Layer 3: Small bubble labels
199+
+ geom_text(
200+
data=labels_df[labels_df["value"] < 10],
201+
mapping=aes(x="x", y="y", label="display_label"),
202+
size=10,
203+
color="white",
204+
fontweight="bold",
185205
)
206+
# Layer 4: Budget value annotations for large/medium bubbles
186207
+ geom_text(
187-
data=labels_df, mapping=aes(x="x", y="y", label="display_label"), size=9, color="white", fontweight="bold"
208+
data=labels_df[labels_df["value"] >= 12],
209+
mapping=aes(x="x", y="y", label="value_label"),
210+
size=10,
211+
color="white",
212+
alpha=0.85,
213+
nudge_y=-0.17,
188214
)
189-
+ scale_fill_manual(values=group_colors)
190-
+ coord_fixed()
191-
+ labs(title="bubble-packed · plotnine · pyplots.ai", fill="Department Group")
215+
# Scales
216+
+ scale_fill_manual(values=group_colors, name="Department Group")
217+
+ scale_alpha_manual(values=alpha_values)
218+
+ guides(alpha=False)
219+
# Tight viewport with coord_fixed for 1:1 aspect ratio
220+
+ coord_fixed(xlim=(cx_mid - half_span, cx_mid + half_span), ylim=(cy_mid - half_span, cy_mid + half_span))
221+
+ labs(title="bubble-packed \u00b7 plotnine \u00b7 pyplots.ai", subtitle=subtitle_text)
222+
# Theme — plotnine's distinctive void theme with layered customization
192223
+ theme_void()
193224
+ theme(
194-
figure_size=(16, 9),
195-
plot_title=element_text(size=24, ha="center", weight="bold"),
196-
legend_title=element_text(size=18),
197-
legend_text=element_text(size=14),
198-
legend_position="right",
199-
plot_background=element_blank(),
225+
figure_size=(12, 12),
226+
plot_title=element_text(size=24, ha="center", weight="bold", margin={"b": 5}),
227+
plot_subtitle=element_text(size=16, ha="center", color="#555555", margin={"t": 5, "b": 10}),
228+
legend_title=element_text(size=18, weight="bold"),
229+
legend_text=element_text(size=16),
230+
legend_position="bottom",
231+
legend_direction="horizontal",
232+
legend_key=element_rect(fill="white", color="none"),
233+
legend_key_size=20,
234+
plot_background=element_rect(fill="white", color="none"),
235+
plot_margin=0.02,
200236
)
201237
)
202238

0 commit comments

Comments
 (0)