Skip to content

Commit c60a009

Browse files
update(bubble-packed): seaborn — comprehensive quality review (#4359)
## Summary Updated **seaborn** implementation for **bubble-packed**. **Changes:** Comprehensive quality review ### Changes - Renamed data dict to sectors for clarity - Removed unused matplotlib.patches import - Simplified circle packing logic - Removed unnecessary 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>
1 parent 7e47de1 commit c60a009

2 files changed

Lines changed: 276 additions & 275 deletions

File tree

Lines changed: 137 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,207 +1,185 @@
11
""" pyplots.ai
22
bubble-packed: Basic Packed Bubble Chart
3-
Library: seaborn 0.13.2 | Python 3.13.11
4-
Quality: 90/100 | Created: 2025-12-23
3+
Library: seaborn 0.13.2 | Python 3.14.3
4+
Quality: 90/100 | Updated: 2026-02-23
55
"""
66

7-
import matplotlib.patches as mpatches
87
import matplotlib.pyplot as plt
98
import numpy as np
109
import pandas as pd
1110
import seaborn as sns
1211

1312

1413
# Data - Company market values by sector (billions USD)
15-
np.random.seed(42)
16-
data = {
14+
sectors = {
1715
"Technology": [("Apple", 180), ("Microsoft", 160), ("Google", 120), ("NVIDIA", 95), ("Meta", 75)],
1816
"Finance": [("JPMorgan", 85), ("Visa", 70), ("Mastercard", 55), ("Goldman Sachs", 45)],
1917
"Healthcare": [("UnitedHealth", 90), ("J&J", 65), ("Merck", 50), ("Pfizer", 40)],
2018
"Retail": [("Amazon", 140), ("Walmart", 60), ("Costco", 45), ("Target", 30)],
2119
}
2220

23-
# Prepare circles sorted by size (largest first for better packing)
24-
all_circles = []
25-
for group, items in data.items():
26-
for name, value in items:
27-
radius = np.sqrt(value) * 4 # Scale by area
28-
all_circles.append({"name": name, "radius": radius, "group": group, "value": value})
29-
30-
all_circles.sort(key=lambda x: -x["radius"])
21+
records = []
22+
for sector, companies in sectors.items():
23+
for name, value in companies:
24+
records.append({"name": name, "value": value, "sector": sector, "radius": np.sqrt(value) * 4})
25+
26+
df = pd.DataFrame(records).sort_values("radius", ascending=False).reset_index(drop=True)
27+
28+
# Circle packing - place circles greedily closest to center without overlap
29+
placed_x, placed_y, placed_r = [], [], []
30+
31+
for _, row in df.iterrows():
32+
r = row["radius"]
33+
34+
if not placed_x:
35+
placed_x.append(0.0)
36+
placed_y.append(0.0)
37+
placed_r.append(r)
38+
continue
39+
40+
best_pos, best_dist = None, float("inf")
41+
px_arr, py_arr, pr_arr = np.array(placed_x), np.array(placed_y), np.array(placed_r)
42+
43+
for i in range(len(placed_x)):
44+
for angle in np.linspace(0, 2 * np.pi, 72, endpoint=False):
45+
gap = placed_r[i] + r + 2
46+
tx = placed_x[i] + gap * np.cos(angle)
47+
ty = placed_y[i] + gap * np.sin(angle)
48+
49+
dists = np.sqrt((px_arr - tx) ** 2 + (py_arr - ty) ** 2)
50+
if np.all(dists >= pr_arr + r + 1):
51+
cdist = np.sqrt(tx**2 + ty**2)
52+
if cdist < best_dist:
53+
best_dist = cdist
54+
best_pos = (tx, ty)
55+
56+
bx, by = best_pos if best_pos else (0.0, 0.0)
57+
placed_x.append(bx)
58+
placed_y.append(by)
59+
placed_r.append(r)
60+
61+
df["x"] = placed_x
62+
df["y"] = placed_y
63+
64+
# Recenter coordinates into positive space
65+
pad = 20
66+
df["x"] = df["x"] - (df["x"] - df["radius"]).min() + pad
67+
df["y"] = df["y"] - (df["y"] - df["radius"]).min() + pad
68+
plot_w = (df["x"] + df["radius"]).max() + pad
69+
plot_h = (df["y"] + df["radius"]).max() + pad
70+
71+
# Seaborn styling - distinctive context and style management
72+
sns.set_context("poster", font_scale=0.85)
73+
sns.set_style("white")
3174

32-
# Circle packing - place circles one by one without overlap (inline, no functions)
33-
placed_circles = []
34-
cx, cy = 0, 0
75+
# Custom colorblind-safe palette anchored on Python Blue (#306998)
76+
sector_order = list(sectors.keys())
77+
base_colors = ["#306998", "#DE8F05", "#029E73", "#CC78BC"]
78+
sector_palette = dict(zip(sector_order, sns.color_palette(base_colors), strict=True))
3579

36-
for circle in all_circles:
37-
new_radius = circle["radius"]
80+
# Square canvas for better packing utilization (bubbles pack roughly circular)
81+
fig, ax = plt.subplots(figsize=(12, 12))
82+
ax.set_xlim(0, plot_w)
83+
ax.set_ylim(0, plot_h)
84+
ax.set_aspect("equal")
3885

39-
if not placed_circles:
40-
# First circle at origin
41-
best_x, best_y = cx, cy
42-
else:
43-
# Try positions around existing circles, find closest to center
44-
best_pos = None
45-
best_dist = float("inf")
46-
47-
for p in placed_circles:
48-
for angle in np.linspace(0, 2 * np.pi, 72, endpoint=False):
49-
dist = p["radius"] + new_radius + 2
50-
test_x = p["x"] + dist * np.cos(angle)
51-
test_y = p["y"] + dist * np.sin(angle)
52-
53-
# Check overlap with all placed circles
54-
valid = True
55-
for other in placed_circles:
56-
d = np.sqrt((test_x - other["x"]) ** 2 + (test_y - other["y"]) ** 2)
57-
if d < other["radius"] + new_radius + 1:
58-
valid = False
59-
break
60-
61-
if valid:
62-
center_dist = np.sqrt((test_x - cx) ** 2 + (test_y - cy) ** 2)
63-
if center_dist < best_dist:
64-
best_dist = center_dist
65-
best_pos = (test_x, test_y)
66-
67-
best_x, best_y = best_pos if best_pos else (cx, cy)
68-
69-
placed_circles.append(
70-
{
71-
"x": best_x,
72-
"y": best_y,
73-
"radius": new_radius,
74-
"name": circle["name"],
75-
"group": circle["group"],
76-
"value": circle["value"],
77-
}
78-
)
79-
80-
# Calculate bounds and recenter
81-
all_x = [c["x"] for c in placed_circles]
82-
all_y = [c["y"] for c in placed_circles]
83-
all_r = [c["radius"] for c in placed_circles]
84-
85-
min_x = min(x - r for x, r in zip(all_x, all_r, strict=True))
86-
max_x = max(x + r for x, r in zip(all_x, all_r, strict=True))
87-
min_y = min(y - r for y, r in zip(all_y, all_r, strict=True))
88-
max_y = max(y + r for y, r in zip(all_y, all_r, strict=True))
89-
90-
# Offset to center in plot area
91-
padding = 20
92-
offset_x = -min_x + padding
93-
offset_y = -min_y + padding
94-
95-
for c in placed_circles:
96-
c["x"] += offset_x
97-
c["y"] += offset_y
98-
99-
plot_width = max_x - min_x + 2 * padding
100-
plot_height = max_y - min_y + 2 * padding
101-
102-
# Create DataFrame for seaborn
103-
df = pd.DataFrame(placed_circles)
104-
# Scale marker size for scatterplot (s parameter uses area in points^2)
105-
df["marker_size"] = (df["radius"] * 2) ** 2 * 3.14 # Convert radius to area for proper sizing
106-
107-
# Set seaborn style
108-
sns.set_style("white")
109-
palette = sns.color_palette("Set2", n_colors=len(data))
110-
group_colors = {group: palette[i] for i, group in enumerate(data.keys())}
86+
# Convert data-unit radii to scatter marker sizes (points²)
87+
fig.canvas.draw()
88+
px_per_unit = ax.transData.transform((1, 0))[0] - ax.transData.transform((0, 0))[0]
89+
pts_per_unit = px_per_unit * 72 / fig.dpi
90+
df["marker_size"] = (df["radius"] * 2 * pts_per_unit) ** 2
11191

112-
# Create figure
113-
fig, ax = plt.subplots(figsize=(16, 9))
92+
# Categorical ordering for consistent palette mapping
93+
df["sector"] = pd.Categorical(df["sector"], categories=sector_order, ordered=True)
11494

115-
# Use seaborn scatterplot for the bubbles
95+
# Draw bubbles with seaborn scatterplot and hue mapping
11696
sns.scatterplot(
11797
data=df,
11898
x="x",
11999
y="y",
120-
hue="group",
100+
hue="sector",
121101
size="marker_size",
122102
sizes=(df["marker_size"].min(), df["marker_size"].max()),
123-
palette="Set2",
124-
alpha=0.9,
103+
hue_order=sector_order,
104+
palette=sector_palette,
105+
alpha=0.92,
125106
edgecolor="white",
126107
linewidth=3,
127-
legend=False,
108+
legend="brief",
128109
ax=ax,
129110
)
130111

131-
# Add labels for all circles using annotations
132-
# Sort by x position to manage external annotation placement
133-
df_sorted = df.sort_values("x")
134-
used_y_positions = [] # Track y positions for external labels to avoid overlap
112+
# Filter legend to sector entries only, then reposition with sns.move_legend
113+
handles, labels = ax.get_legend_handles_labels()
114+
sector_h = [h for h, lab in zip(handles, labels, strict=False) if lab in sector_order]
115+
sector_lab = [lab for lab in labels if lab in sector_order]
116+
ax.legend(sector_h, sector_lab)
117+
sns.move_legend(
118+
ax,
119+
loc="upper center",
120+
bbox_to_anchor=(0.5, -0.02),
121+
ncol=4,
122+
fontsize=16,
123+
framealpha=0.95,
124+
title="Sector",
125+
title_fontsize=18,
126+
edgecolor="#CCCCCC",
127+
)
135128

136-
for _, row in df_sorted.iterrows():
129+
# Labels with value annotations for data storytelling
130+
for _, row in df.iterrows():
131+
r = row["radius"]
137132
name = row["name"]
138-
# Abbreviate long names for internal labels
139-
short_name = name if len(name) <= 10 else name[:9] + "."
140-
141-
if row["radius"] > 38:
142-
# Large circles - full name with large font
143-
ax.text(row["x"], row["y"], short_name, ha="center", va="center", fontsize=18, fontweight="bold", color="white")
144-
elif row["radius"] > 32:
145-
# Medium-large circles
146-
ax.text(row["x"], row["y"], short_name, ha="center", va="center", fontsize=14, fontweight="bold", color="white")
147-
elif row["radius"] > 26:
148-
# Medium circles - smaller font
149-
ax.text(row["x"], row["y"], short_name, ha="center", va="center", fontsize=11, fontweight="bold", color="white")
133+
value = row["value"]
134+
135+
if r > 38:
136+
fs_name, max_chars, show_val = 20, 12, True
137+
elif r > 30:
138+
fs_name, max_chars, show_val = 16, 12, True
139+
elif r > 24:
140+
fs_name, max_chars, show_val = 12, 10, True
150141
else:
151-
# Small circles - external annotation with arrow
152-
# Determine label position (alternate left/right based on position)
153-
if row["x"] < plot_width / 2:
154-
# Left side - annotate to the left
155-
offset_x = -row["radius"] - 20
156-
ha = "right"
157-
else:
158-
# Right side - annotate to the right
159-
offset_x = row["radius"] + 20
160-
ha = "left"
161-
162-
# Adjust y to avoid overlapping labels
163-
target_y = row["y"]
164-
for used_y in used_y_positions:
165-
if abs(target_y - used_y) < 25:
166-
target_y = used_y + 25 if target_y >= used_y else used_y - 25
167-
used_y_positions.append(target_y)
168-
169-
ax.annotate(
142+
fs_name, max_chars, show_val = 9, 8, False
143+
144+
if len(name) > max_chars:
145+
name = name[: max_chars - 1] + "."
146+
147+
if show_val:
148+
y_off = r * 0.13
149+
ax.text(
150+
row["x"],
151+
row["y"] + y_off,
170152
name,
171-
xy=(row["x"], row["y"]),
172-
xytext=(row["x"] + offset_x, target_y),
173-
fontsize=11,
153+
ha="center",
154+
va="center",
155+
fontsize=fs_name,
174156
fontweight="bold",
175-
color="#444444",
176-
arrowprops={"arrowstyle": "->", "color": "#888888", "lw": 1.5, "connectionstyle": "arc3,rad=0.1"},
177-
ha=ha,
157+
color="white",
158+
)
159+
ax.text(
160+
row["x"],
161+
row["y"] - y_off * 2,
162+
f"${value}B",
163+
ha="center",
178164
va="center",
165+
fontsize=fs_name - 4,
166+
color="white",
167+
alpha=0.8,
179168
)
169+
else:
170+
ax.text(row["x"], row["y"], name, ha="center", va="center", fontsize=fs_name, fontweight="bold", color="white")
180171

181-
# Configure axes
182-
ax.set_xlim(0, plot_width)
183-
ax.set_ylim(0, plot_height)
184-
ax.set_aspect("equal")
185172
ax.axis("off")
186173

187174
# Title
188-
ax.set_title("bubble-packed · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=20)
189-
190-
# Create legend - position below the plot to avoid any overlap with data
191-
legend_elements = [
192-
mpatches.Patch(facecolor=group_colors[group], edgecolor="white", linewidth=2, label=group) for group in data.keys()
193-
]
194-
ax.legend(
195-
handles=legend_elements,
196-
loc="upper center",
197-
bbox_to_anchor=(0.5, -0.02),
198-
ncol=4,
199-
fontsize=14,
200-
framealpha=0.95,
201-
title="Sector",
202-
title_fontsize=16,
203-
edgecolor="gray",
175+
ax.set_title(
176+
"Market Capitalization by Sector\nbubble-packed \u00b7 seaborn \u00b7 pyplots.ai",
177+
fontsize=26,
178+
fontweight="medium",
179+
pad=25,
180+
linespacing=1.4,
204181
)
205182

183+
sns.despine(left=True, bottom=True)
206184
plt.tight_layout()
207185
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")

0 commit comments

Comments
 (0)