|
1 | 1 | """ pyplots.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import math |
|
11 | 11 | from pygal.style import Style |
12 | 12 |
|
13 | 13 |
|
14 | | -# Data - Department budget allocation (values determine circle size) |
| 14 | +# Department budget allocation data ($K, determines circle area) |
15 | 15 | data = [ |
16 | 16 | {"label": "Software Development", "value": 450, "group": "Technology"}, |
17 | 17 | {"label": "Cloud Infrastructure", "value": 280, "group": "Technology"}, |
|
31 | 31 | {"label": "Support", "value": 110, "group": "Sales"}, |
32 | 32 | ] |
33 | 33 |
|
34 | | -# Chart dimensions |
35 | 34 | WIDTH = 4800 |
36 | 35 | HEIGHT = 2700 |
37 | 36 | PADDING = 15 |
| 37 | +FONT_FAMILY = "'Trebuchet MS', 'Lucida Grande', sans-serif" |
38 | 38 |
|
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"] |
41 | 42 |
|
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) |
43 | 49 | max_val = max(item["value"] for item in data) |
44 | 50 | max_radius = min(WIDTH, HEIGHT) * 0.11 |
45 | 51 |
|
46 | 52 | circles = [] |
47 | 53 | for item in data: |
48 | 54 | 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}) |
50 | 56 |
|
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"])) |
53 | 62 |
|
54 | | -# Place first circle at center |
| 63 | +# Packing center |
55 | 64 | cx, cy = WIDTH / 2, HEIGHT / 2 |
| 65 | + |
| 66 | +# Place first circle at center |
56 | 67 | circles[0]["x"] = cx |
57 | 68 | circles[0]["y"] = cy |
58 | 69 | placed = [circles[0]] |
59 | 70 |
|
60 | | -# Place remaining circles using greedy packing |
| 71 | +# Greedy circle packing with group-affinity clustering |
61 | 72 | for circle in circles[1:]: |
62 | 73 | 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"]] |
64 | 76 |
|
65 | | - # Try placing adjacent to each existing circle |
66 | 77 | for existing in placed: |
67 | | - for angle_deg in range(0, 360, 8): |
| 78 | + for angle_deg in range(0, 360, 6): |
68 | 79 | angle = math.radians(angle_deg) |
69 | 80 | dist = existing["r"] + circle["r"] + PADDING |
70 | 81 | nx = existing["x"] + math.cos(angle) * dist |
71 | 82 | ny = existing["y"] + math.sin(angle) * dist |
72 | 83 |
|
73 | | - # Check for overlaps |
74 | 84 | valid = True |
75 | 85 | for other in placed: |
76 | | - dx = nx - other["x"] |
77 | | - dy = ny - other["y"] |
| 86 | + ddx = nx - other["x"] |
| 87 | + ddy = ny - other["y"] |
78 | 88 | 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: |
80 | 90 | valid = False |
81 | 91 | break |
82 | 92 |
|
83 | 93 | 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 |
87 | 105 | best_pos = (nx, ny) |
88 | 106 |
|
89 | 107 | if best_pos: |
|
94 | 112 |
|
95 | 113 | placed.append(circle) |
96 | 114 |
|
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 | + |
98 | 141 | packed = [(c["x"], c["y"], c["r"], c["item"]) for c in placed] |
99 | 142 |
|
100 | | -# Custom style |
| 143 | +# Pygal style with explicit typography |
101 | 144 | custom_style = Style( |
102 | 145 | background="white", |
103 | 146 | plot_background="white", |
104 | 147 | foreground="#333", |
105 | | - foreground_strong="#333", |
106 | | - foreground_subtle="#666", |
| 148 | + foreground_strong="#222", |
| 149 | + foreground_subtle="#888", |
107 | 150 | colors=list(GROUP_COLORS.values()), |
| 151 | + font_family=FONT_FAMILY, |
108 | 152 | title_font_size=72, |
109 | 153 | legend_font_size=42, |
| 154 | + value_font_size=32, |
110 | 155 | ) |
111 | 156 |
|
112 | | -# Create base chart (Pie with no data, just for structure) |
| 157 | +# Pygal chart scaffold (Pie provides legend/title infrastructure) |
113 | 158 | chart = pygal.Pie( |
114 | 159 | width=WIDTH, |
115 | 160 | height=HEIGHT, |
116 | 161 | style=custom_style, |
117 | | - title="bubble-packed · pygal · pyplots.ai", |
| 162 | + title="bubble-packed \u00b7 pygal \u00b7 pyplots.ai", |
118 | 163 | show_legend=True, |
119 | 164 | legend_at_bottom=True, |
120 | 165 | legend_at_bottom_columns=4, |
| 166 | + legend_box_size=28, |
121 | 167 | 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, |
123 | 173 | ) |
124 | 174 |
|
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", []) |
128 | 178 |
|
129 | 179 |
|
130 | | -# XML filter to add packed bubbles to SVG |
131 | 180 | 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 | + |
132 | 216 | g = etree.SubElement(root, "g") |
133 | 217 | g.set("class", "packed-bubbles") |
134 | 218 |
|
| 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 |
135 | 241 | 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" |
164 | 300 |
|
165 | 301 | return root |
166 | 302 |
|
167 | 303 |
|
168 | 304 | chart.add_xml_filter(add_packed_bubbles) |
169 | 305 |
|
170 | | -# Render and save |
171 | 306 | chart.render_to_png("plot.png") |
172 | 307 | chart.render_to_file("plot.html") |
0 commit comments