Skip to content

Commit 14dbb02

Browse files
feat(pygal): implement bubble-packed (#1073)
## Implementation: `bubble-packed` - pygal Implements the **pygal** version of `bubble-packed`. **File:** `plots/bubble-packed/implementations/pygal.py` **Parent Issue:** #992 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20279594554)* --------- 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 c7e95ff commit 14dbb02

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
bubble-packed: Basic Packed Bubble Chart
3+
Library: pygal
4+
5+
Uses pygal's XML filter to add packed circle elements.
6+
Pygal doesn't have native packed bubbles, so we use circle packing algorithm.
7+
"""
8+
9+
import math
10+
11+
import pygal
12+
from pygal.etree import etree
13+
from pygal.style import Style
14+
15+
16+
# Data - Department budget allocation (values determine circle size)
17+
data = [
18+
{"label": "Software Development", "value": 450, "group": "Technology"},
19+
{"label": "Cloud Infrastructure", "value": 280, "group": "Technology"},
20+
{"label": "Data Analytics", "value": 180, "group": "Technology"},
21+
{"label": "Security", "value": 120, "group": "Technology"},
22+
{"label": "Digital Marketing", "value": 350, "group": "Marketing"},
23+
{"label": "Brand & Creative", "value": 220, "group": "Marketing"},
24+
{"label": "Events", "value": 150, "group": "Marketing"},
25+
{"label": "PR", "value": 90, "group": "Marketing"},
26+
{"label": "Facilities", "value": 280, "group": "Operations"},
27+
{"label": "HR & Recruiting", "value": 200, "group": "Operations"},
28+
{"label": "Legal", "value": 160, "group": "Operations"},
29+
{"label": "Admin", "value": 100, "group": "Operations"},
30+
{"label": "Enterprise", "value": 380, "group": "Sales"},
31+
{"label": "SMB", "value": 250, "group": "Sales"},
32+
{"label": "Partners", "value": 170, "group": "Sales"},
33+
{"label": "Support", "value": 110, "group": "Sales"},
34+
]
35+
36+
37+
# Circle packing algorithm
38+
def pack_circles(items, width, height, padding=15):
39+
"""Pack circles together without overlap using greedy placement."""
40+
if not items:
41+
return []
42+
43+
# Scale values to radii (by area for accurate visual perception)
44+
max_val = max(item["value"] for item in items)
45+
max_radius = min(width, height) * 0.11
46+
47+
circles = []
48+
for item in items:
49+
r = math.sqrt(item["value"] / max_val) * max_radius
50+
circles.append({"r": r, "item": item, "x": 0, "y": 0})
51+
52+
# Sort by radius descending for better packing
53+
circles.sort(key=lambda c: -c["r"])
54+
55+
# Place first circle at center
56+
cx, cy = width / 2, height / 2
57+
circles[0]["x"] = cx
58+
circles[0]["y"] = cy
59+
placed = [circles[0]]
60+
61+
# Place remaining circles
62+
for circle in circles[1:]:
63+
best_pos = None
64+
min_dist_from_center = float("inf")
65+
66+
# Try placing adjacent to each existing circle
67+
for existing in placed:
68+
for angle_deg in range(0, 360, 8):
69+
angle = math.radians(angle_deg)
70+
dist = existing["r"] + circle["r"] + padding
71+
nx = existing["x"] + math.cos(angle) * dist
72+
ny = existing["y"] + math.sin(angle) * dist
73+
74+
# Check for overlaps
75+
valid = True
76+
for other in placed:
77+
dx = nx - other["x"]
78+
dy = ny - other["y"]
79+
min_gap = circle["r"] + other["r"] + padding * 0.5
80+
if math.sqrt(dx * dx + dy * dy) < min_gap:
81+
valid = False
82+
break
83+
84+
if valid:
85+
d = math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2)
86+
if d < min_dist_from_center:
87+
min_dist_from_center = d
88+
best_pos = (nx, ny)
89+
90+
if best_pos:
91+
circle["x"], circle["y"] = best_pos
92+
else:
93+
circle["x"] = cx
94+
circle["y"] = max(c["y"] + c["r"] for c in placed) + circle["r"] + padding
95+
96+
placed.append(circle)
97+
98+
return [(c["x"], c["y"], c["r"], c["item"]) for c in placed]
99+
100+
101+
# Chart dimensions
102+
WIDTH = 4800
103+
HEIGHT = 2700
104+
105+
# Pack circles
106+
packed = pack_circles(data, WIDTH, HEIGHT)
107+
108+
# Group colors matching pyplots style
109+
GROUP_COLORS = {"Technology": "#306998", "Marketing": "#FFD43B", "Operations": "#4ECDC4", "Sales": "#FF6B6B"}
110+
111+
# Custom style
112+
custom_style = Style(
113+
background="white",
114+
plot_background="white",
115+
foreground="#333",
116+
foreground_strong="#333",
117+
foreground_subtle="#666",
118+
colors=list(GROUP_COLORS.values()),
119+
title_font_size=72,
120+
legend_font_size=42,
121+
)
122+
123+
# Create base chart (Pie with no data, just for structure)
124+
chart = pygal.Pie(
125+
width=WIDTH,
126+
height=HEIGHT,
127+
style=custom_style,
128+
title="bubble-packed · pygal · pyplots.ai",
129+
show_legend=True,
130+
legend_at_bottom=True,
131+
legend_at_bottom_columns=4,
132+
inner_radius=0,
133+
margin=100,
134+
)
135+
136+
# Add legend entries (empty slices just for legend)
137+
for group in ["Technology", "Marketing", "Operations", "Sales"]:
138+
chart.add(group, [])
139+
140+
141+
# XML filter to add packed bubbles
142+
def add_packed_bubbles(root):
143+
"""Add packed bubble circles to SVG."""
144+
# Create group for bubbles
145+
g = etree.SubElement(root, "g")
146+
g.set("class", "packed-bubbles")
147+
148+
for x, y, r, item in packed:
149+
color = GROUP_COLORS[item["group"]]
150+
151+
# Circle element
152+
circle = etree.SubElement(g, "circle")
153+
circle.set("cx", f"{x:.1f}")
154+
circle.set("cy", f"{y:.1f}")
155+
circle.set("r", f"{r:.1f}")
156+
circle.set("fill", color)
157+
circle.set("fill-opacity", "0.85")
158+
circle.set("stroke", "#333")
159+
circle.set("stroke-width", "3")
160+
161+
# Tooltip
162+
title = etree.SubElement(circle, "title")
163+
title.text = f"{item['label']}: ${item['value']}K"
164+
165+
# Value label for large circles
166+
if r > 80:
167+
text = etree.SubElement(g, "text")
168+
text.set("x", f"{x:.1f}")
169+
text.set("y", f"{y:.1f}")
170+
text.set("text-anchor", "middle")
171+
text.set("dominant-baseline", "middle")
172+
text.set("fill", "white")
173+
text.set("font-size", f"{int(r * 0.32)}")
174+
text.set("font-family", "sans-serif")
175+
text.set("font-weight", "bold")
176+
text.text = f"${item['value']}K"
177+
178+
return root
179+
180+
181+
chart.add_xml_filter(add_packed_bubbles)
182+
183+
# Render and save
184+
chart.render_to_png("plot.png")
185+
chart.render_to_file("plot.html")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Per-library metadata for pygal implementation of bubble-packed
2+
# Auto-generated by impl-generate.yml
3+
4+
library: pygal
5+
specification_id: bubble-packed
6+
7+
# Preview URLs (filled by workflow)
8+
preview_url: https://storage.googleapis.com/pyplots-images/plots/bubble-packed/pygal/plot.png
9+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/bubble-packed/pygal/plot_thumb.png
10+
preview_html: https://storage.googleapis.com/pyplots-images/plots/bubble-packed/pygal/plot.html
11+
12+
current:
13+
version: 0
14+
generated_at: 2025-12-16T19:10:00Z
15+
generated_by: claude-opus-4-5-20251101
16+
workflow_run: 20279594554
17+
issue: 992
18+
quality_score: 91
19+
# Version info (filled by workflow)
20+
python_version: "3.13.11"
21+
library_version: "unknown"
22+
23+
history: []

0 commit comments

Comments
 (0)