Skip to content

Commit a18cfd4

Browse files
update(bubble-packed): pygal — comprehensive quality review (#4364)
## Summary Updated **pygal** implementation for **bubble-packed**. **Changes:** Comprehensive quality review ### Changes - Improved bubble labeling: multi-line labels for large circles, value-only for medium - Changed stroke from dark grey to white with thicker width - Added no_data_text config - Enhanced label visibility and readability ## 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 7b6ec74 commit a18cfd4

2 files changed

Lines changed: 346 additions & 173 deletions

File tree

Lines changed: 197 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" pyplots.ai
22
bubble-packed: Basic Packed Bubble Chart
3-
Library: pygal 3.1.0 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 86/100 | Updated: 2026-02-23
55
"""
66

77
import math
@@ -11,7 +11,7 @@
1111
from pygal.style import Style
1212

1313

14-
# Data - Department budget allocation (values determine circle size)
14+
# Department budget allocation data ($K, determines circle area)
1515
data = [
1616
{"label": "Software Development", "value": 450, "group": "Technology"},
1717
{"label": "Cloud Infrastructure", "value": 280, "group": "Technology"},
@@ -31,59 +31,77 @@
3131
{"label": "Support", "value": 110, "group": "Sales"},
3232
]
3333

34-
# Chart dimensions
3534
WIDTH = 4800
3635
HEIGHT = 2700
3736
PADDING = 15
37+
FONT_FAMILY = "'Trebuchet MS', 'Lucida Grande', sans-serif"
3838

39-
# Group colors matching pyplots style
40-
GROUP_COLORS = {"Technology": "#306998", "Marketing": "#FFD43B", "Operations": "#4ECDC4", "Sales": "#FF6B6B"}
39+
# Colorblind-safe palette with strong contrast on white
40+
GROUP_COLORS = {"Technology": "#306998", "Marketing": "#CC8400", "Operations": "#1A8A72", "Sales": "#C94D46"}
41+
GROUP_NAMES = ["Technology", "Marketing", "Operations", "Sales"]
4142

42-
# Scale values to radii (by area for accurate visual perception)
43+
# Compute group totals for labels and sorting
44+
group_totals = {}
45+
for item in data:
46+
group_totals[item["group"]] = group_totals.get(item["group"], 0) + item["value"]
47+
48+
# Scale values to radii (sqrt for area-based visual perception)
4349
max_val = max(item["value"] for item in data)
4450
max_radius = min(WIDTH, HEIGHT) * 0.11
4551

4652
circles = []
4753
for item in data:
4854
r = math.sqrt(item["value"] / max_val) * max_radius
49-
circles.append({"r": r, "item": item, "x": 0, "y": 0})
55+
circles.append({"r": r, "item": item, "x": 0.0, "y": 0.0})
5056

51-
# Sort by radius descending for better packing
52-
circles.sort(key=lambda c: -c["r"])
57+
# Sort by group (largest budget group first for central placement),
58+
# then by descending radius within each group
59+
sorted_groups = sorted(GROUP_NAMES, key=lambda g: -group_totals[g])
60+
group_order = {g: i for i, g in enumerate(sorted_groups)}
61+
circles.sort(key=lambda c: (group_order[c["item"]["group"]], -c["r"]))
5362

54-
# Place first circle at center
63+
# Packing center
5564
cx, cy = WIDTH / 2, HEIGHT / 2
65+
66+
# Place first circle at center
5667
circles[0]["x"] = cx
5768
circles[0]["y"] = cy
5869
placed = [circles[0]]
5970

60-
# Place remaining circles using greedy packing
71+
# Greedy circle packing with group-affinity clustering
6172
for circle in circles[1:]:
6273
best_pos = None
63-
min_dist_from_center = float("inf")
74+
best_score = float("inf")
75+
same_group = [p for p in placed if p["item"]["group"] == circle["item"]["group"]]
6476

65-
# Try placing adjacent to each existing circle
6677
for existing in placed:
67-
for angle_deg in range(0, 360, 8):
78+
for angle_deg in range(0, 360, 6):
6879
angle = math.radians(angle_deg)
6980
dist = existing["r"] + circle["r"] + PADDING
7081
nx = existing["x"] + math.cos(angle) * dist
7182
ny = existing["y"] + math.sin(angle) * dist
7283

73-
# Check for overlaps
7484
valid = True
7585
for other in placed:
76-
dx = nx - other["x"]
77-
dy = ny - other["y"]
86+
ddx = nx - other["x"]
87+
ddy = ny - other["y"]
7888
min_gap = circle["r"] + other["r"] + PADDING * 0.5
79-
if math.sqrt(dx * dx + dy * dy) < min_gap:
89+
if math.sqrt(ddx * ddx + ddy * ddy) < min_gap:
8090
valid = False
8191
break
8292

8393
if valid:
84-
d = math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)
85-
if d < min_dist_from_center:
86-
min_dist_from_center = d
94+
d_center = math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)
95+
if same_group:
96+
d_group = sum(math.sqrt((nx - p["x"]) ** 2 + (ny - p["y"]) ** 2) for p in same_group) / len(
97+
same_group
98+
)
99+
score = d_center * 0.3 + d_group * 0.7
100+
else:
101+
score = d_center
102+
103+
if score < best_score:
104+
best_score = score
87105
best_pos = (nx, ny)
88106

89107
if best_pos:
@@ -94,79 +112,196 @@
94112

95113
placed.append(circle)
96114

97-
# Prepare packed data for rendering
115+
# Recenter layout using area-weighted centroid for balanced visual appearance
116+
avail_top = 180
117+
avail_bottom = HEIGHT - 300
118+
target_cy = (avail_top + avail_bottom) / 2
119+
target_cx = WIDTH / 2
120+
121+
total_area = sum(c["r"] ** 2 for c in placed)
122+
weighted_cx = sum(c["x"] * c["r"] ** 2 for c in placed) / total_area
123+
weighted_cy = sum(c["y"] * c["r"] ** 2 for c in placed) / total_area
124+
dx = target_cx - weighted_cx
125+
dy = target_cy - weighted_cy
126+
127+
for c in placed:
128+
c["x"] += dx
129+
c["y"] += dy
130+
131+
# Compute group centroids and bounds
132+
group_info = {}
133+
for c in placed:
134+
g = c["item"]["group"]
135+
if g not in group_info:
136+
group_info[g] = {"xs": [], "ys": [], "rs": []}
137+
group_info[g]["xs"].append(c["x"])
138+
group_info[g]["ys"].append(c["y"])
139+
group_info[g]["rs"].append(c["r"])
140+
98141
packed = [(c["x"], c["y"], c["r"], c["item"]) for c in placed]
99142

100-
# Custom style
143+
# Pygal style with explicit typography
101144
custom_style = Style(
102145
background="white",
103146
plot_background="white",
104147
foreground="#333",
105-
foreground_strong="#333",
106-
foreground_subtle="#666",
148+
foreground_strong="#222",
149+
foreground_subtle="#888",
107150
colors=list(GROUP_COLORS.values()),
151+
font_family=FONT_FAMILY,
108152
title_font_size=72,
109153
legend_font_size=42,
154+
value_font_size=32,
110155
)
111156

112-
# Create base chart (Pie with no data, just for structure)
157+
# Pygal chart scaffold (Pie provides legend/title infrastructure)
113158
chart = pygal.Pie(
114159
width=WIDTH,
115160
height=HEIGHT,
116161
style=custom_style,
117-
title="bubble-packed · pygal · pyplots.ai",
162+
title="bubble-packed \u00b7 pygal \u00b7 pyplots.ai",
118163
show_legend=True,
119164
legend_at_bottom=True,
120165
legend_at_bottom_columns=4,
166+
legend_box_size=28,
121167
inner_radius=0,
122-
margin=100,
168+
margin=80,
169+
no_data_text="",
170+
tooltip_fancy_mode=True,
171+
pretty_print=True,
172+
truncate_legend=-1,
123173
)
124174

125-
# Add legend entries (empty slices just for legend)
126-
for group in ["Technology", "Marketing", "Operations", "Sales"]:
127-
chart.add(group, [])
175+
# Legend entries with formatted group totals
176+
for group in GROUP_NAMES:
177+
chart.add(f"{group}: ${group_totals[group]:,}K", [])
128178

129179

130-
# XML filter to add packed bubbles to SVG
131180
def add_packed_bubbles(root):
181+
"""Render packed bubble clusters via pygal's SVG filter pipeline."""
182+
183+
def _text(parent, x, y, label, size, color, bold=False):
184+
"""Create an SVG text element with consistent styling."""
185+
t = etree.SubElement(parent, "text")
186+
t.set("x", f"{x:.0f}")
187+
t.set("y", f"{y:.0f}")
188+
t.set("text-anchor", "middle")
189+
t.set("dominant-baseline", "middle")
190+
t.set("fill", color)
191+
t.set("font-size", f"{size}")
192+
t.set("font-family", FONT_FAMILY)
193+
if bold:
194+
t.set("font-weight", "bold")
195+
t.text = label
196+
197+
# SVG defs: radial gradients for polished 3D bubble appearance
198+
defs = etree.SubElement(root, "defs")
199+
for gname, color in GROUP_COLORS.items():
200+
grad = etree.SubElement(defs, "radialGradient")
201+
grad.set("id", f"grad-{gname.lower()}")
202+
grad.set("cx", "35%")
203+
grad.set("cy", "35%")
204+
grad.set("r", "65%")
205+
rgb = [int(color[i : i + 2], 16) for i in (1, 3, 5)]
206+
light = [min(255, c + 60) for c in rgb]
207+
stop1 = etree.SubElement(grad, "stop")
208+
stop1.set("offset", "0%")
209+
stop1.set("stop-color", f"#{light[0]:02x}{light[1]:02x}{light[2]:02x}")
210+
stop1.set("stop-opacity", "0.95")
211+
stop2 = etree.SubElement(grad, "stop")
212+
stop2.set("offset", "100%")
213+
stop2.set("stop-color", color)
214+
stop2.set("stop-opacity", "0.90")
215+
132216
g = etree.SubElement(root, "g")
133217
g.set("class", "packed-bubbles")
134218

219+
overall_cy = sum(c[1] for c in packed) / len(packed)
220+
221+
# Subtle dashed group boundary indicators
222+
for gname, gdata in group_info.items():
223+
gcx = sum(gdata["xs"]) / len(gdata["xs"])
224+
gcy = sum(gdata["ys"]) / len(gdata["ys"])
225+
extent = max(
226+
math.sqrt((x - gcx) ** 2 + (y - gcy) ** 2) + r
227+
for x, y, r in zip(gdata["xs"], gdata["ys"], gdata["rs"], strict=True)
228+
)
229+
bg = etree.SubElement(g, "circle")
230+
bg.set("cx", f"{gcx:.0f}")
231+
bg.set("cy", f"{gcy:.0f}")
232+
bg.set("r", f"{extent + 24:.0f}")
233+
bg.set("fill", GROUP_COLORS[gname])
234+
bg.set("fill-opacity", "0.05")
235+
bg.set("stroke", GROUP_COLORS[gname])
236+
bg.set("stroke-opacity", "0.18")
237+
bg.set("stroke-width", "2")
238+
bg.set("stroke-dasharray", "12,8")
239+
240+
# Data circles with gradient fills
135241
for x, y, r, item in packed:
136-
color = GROUP_COLORS[item["group"]]
137-
138-
# Circle element
139-
circle_elem = etree.SubElement(g, "circle")
140-
circle_elem.set("cx", f"{x:.1f}")
141-
circle_elem.set("cy", f"{y:.1f}")
142-
circle_elem.set("r", f"{r:.1f}")
143-
circle_elem.set("fill", color)
144-
circle_elem.set("fill-opacity", "0.85")
145-
circle_elem.set("stroke", "#333")
146-
circle_elem.set("stroke-width", "3")
147-
148-
# Tooltip
149-
title = etree.SubElement(circle_elem, "title")
150-
title.text = f"{item['label']}: ${item['value']}K"
151-
152-
# Value label for large circles
153-
if r > 80:
154-
text = etree.SubElement(g, "text")
155-
text.set("x", f"{x:.1f}")
156-
text.set("y", f"{y:.1f}")
157-
text.set("text-anchor", "middle")
158-
text.set("dominant-baseline", "middle")
159-
text.set("fill", "white")
160-
text.set("font-size", f"{int(r * 0.32)}")
161-
text.set("font-family", "sans-serif")
162-
text.set("font-weight", "bold")
163-
text.text = f"${item['value']}K"
242+
grad_id = f"grad-{item['group'].lower()}"
243+
circ = etree.SubElement(g, "circle")
244+
circ.set("cx", f"{x:.1f}")
245+
circ.set("cy", f"{y:.1f}")
246+
circ.set("r", f"{r:.1f}")
247+
circ.set("fill", f"url(#{grad_id})")
248+
circ.set("stroke", "white")
249+
circ.set("stroke-width", "4")
250+
251+
title = etree.SubElement(circ, "title")
252+
title.text = f"{item['label']}: ${item['value']}K ({item['group']})"
253+
254+
# Highlight ring on the largest circle for visual emphasis
255+
top = max(packed, key=lambda c: c[2])
256+
ring = etree.SubElement(g, "circle")
257+
ring.set("cx", f"{top[0]:.1f}")
258+
ring.set("cy", f"{top[1]:.1f}")
259+
ring.set("r", f"{top[2] + 8:.1f}")
260+
ring.set("fill", "none")
261+
ring.set("stroke", GROUP_COLORS[top[3]["group"]])
262+
ring.set("stroke-width", "3")
263+
ring.set("stroke-opacity", "0.40")
264+
ring.set("stroke-dasharray", "8,5")
265+
266+
# Circle labels (consolidated via _text helper)
267+
for x, y, r, item in packed:
268+
text_color = "#333" if item["group"] == "Marketing" else "white"
269+
if r > 110:
270+
fs = max(int(r * 0.22), 28)
271+
_text(g, x, y - fs * 0.55, item["label"].split()[0], fs, text_color, bold=True)
272+
_text(g, x, y + fs * 0.65, f"${item['value']}K", int(fs * 0.82), text_color)
273+
elif r > 55:
274+
fs = max(int(r * 0.30), 24)
275+
_text(g, x, y, f"${item['value']}K", fs, text_color, bold=True)
276+
else:
277+
fs = max(int(r * 0.38), 22)
278+
_text(g, x, y, f"${item['value']}K", fs, text_color)
279+
280+
# Group labels with consistent outward placement and increased padding
281+
for gname, gdata in group_info.items():
282+
gcx = sum(gdata["xs"]) / len(gdata["xs"])
283+
gcy = sum(gdata["ys"]) / len(gdata["ys"])
284+
285+
if gcy < overall_cy:
286+
label_y = min(y - r for y, r in zip(gdata["ys"], gdata["rs"], strict=True)) - 40
287+
else:
288+
label_y = max(y + r for y, r in zip(gdata["ys"], gdata["rs"], strict=True)) + 60
289+
290+
lbl = etree.SubElement(g, "text")
291+
lbl.set("x", f"{gcx:.0f}")
292+
lbl.set("y", f"{label_y:.0f}")
293+
lbl.set("text-anchor", "middle")
294+
lbl.set("fill", GROUP_COLORS[gname])
295+
lbl.set("font-size", "38")
296+
lbl.set("font-family", FONT_FAMILY)
297+
lbl.set("font-weight", "bold")
298+
lbl.set("letter-spacing", "1.5")
299+
lbl.text = f"{gname}: ${group_totals[gname]:,}K"
164300

165301
return root
166302

167303

168304
chart.add_xml_filter(add_packed_bubbles)
169305

170-
# Render and save
171306
chart.render_to_png("plot.png")
172307
chart.render_to_file("plot.html")

0 commit comments

Comments
 (0)