|
1 | 1 | """ pyplots.ai |
2 | 2 | bubble-packed: Basic Packed Bubble Chart |
3 | | -Library: matplotlib 3.10.8 | Python 3.13.11 |
4 | | -Quality: 92/100 | Created: 2025-12-23 |
| 3 | +Library: matplotlib 3.10.8 | Python 3.14.3 |
| 4 | +Quality: 87/100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import matplotlib.collections as mcoll |
7 | 8 | import matplotlib.patches as mpatches |
| 9 | +import matplotlib.patheffects as pe |
8 | 10 | import matplotlib.pyplot as plt |
9 | 11 | import numpy as np |
10 | 12 |
|
11 | 13 |
|
12 | 14 | # Data - Department budget allocation (in thousands) |
13 | | -np.random.seed(42) |
14 | 15 | labels = [ |
15 | 16 | "Engineering", |
16 | 17 | "Marketing", |
|
28 | 29 | "Security", |
29 | 30 | "QA", |
30 | 31 | ] |
31 | | -values = [850, 420, 680, 320, 180, 290, 750, 210, 150, 380, 240, 550, 460, 170, 195] |
32 | | - |
33 | | -# Colors by group (Python Blue primary, Yellow secondary, others colorblind-safe) |
34 | | -colors = [ |
35 | | - "#306998", # Engineering - Blue (Tech) |
36 | | - "#FFD43B", # Marketing - Yellow (Business) |
37 | | - "#306998", # Sales - Blue (Revenue) |
38 | | - "#4A90A4", # Operations - Teal (Support) |
39 | | - "#4A90A4", # HR - Teal (Support) |
40 | | - "#4A90A4", # Finance - Teal (Support) |
41 | | - "#FFD43B", # R&D - Yellow (Innovation) |
42 | | - "#4A90A4", # Customer Support - Teal (Support) |
43 | | - "#7B9E89", # Legal - Sage (Compliance) |
44 | | - "#306998", # IT - Blue (Tech) |
45 | | - "#FFD43B", # Design - Yellow (Creative) |
46 | | - "#306998", # Product - Blue (Tech) |
47 | | - "#FFD43B", # Data Science - Yellow (Analytics) |
48 | | - "#7B9E89", # Security - Sage (Compliance) |
49 | | - "#7B9E89", # QA - Sage (Quality) |
50 | | -] |
| 32 | +values = [950, 420, 680, 310, 160, 280, 820, 200, 130, 370, 230, 580, 470, 145, 175] |
| 33 | + |
| 34 | +# Group assignments - realistic organizational structure |
| 35 | +group_map = { |
| 36 | + "Engineering": "Engineering", |
| 37 | + "IT": "Engineering", |
| 38 | + "Data Science": "Engineering", |
| 39 | + "R&D": "Engineering", |
| 40 | + "Marketing": "Business", |
| 41 | + "Sales": "Business", |
| 42 | + "Product": "Business", |
| 43 | + "Design": "Business", |
| 44 | + "Operations": "Operations", |
| 45 | + "HR": "Operations", |
| 46 | + "Finance": "Operations", |
| 47 | + "Customer Support": "Operations", |
| 48 | + "Legal": "Compliance", |
| 49 | + "Security": "Compliance", |
| 50 | + "QA": "Compliance", |
| 51 | +} |
| 52 | + |
| 53 | +# Colorblind-safe palette with high hue separation |
| 54 | +group_colors = {"Engineering": "#306998", "Business": "#E8C33A", "Operations": "#D4654A", "Compliance": "#8B6DB0"} |
| 55 | +colors = [group_colors[group_map[label]] for label in labels] |
51 | 56 |
|
52 | 57 | # Scale values to radius (sqrt for area-proportional sizing) |
53 | | -min_radius = 0.35 |
54 | | -max_radius = 1.9 |
55 | | -values_array = np.array(values) |
| 58 | +min_radius = 0.30 |
| 59 | +max_radius = 2.0 |
| 60 | +values_array = np.array(values, dtype=float) |
56 | 61 | radii = min_radius + (max_radius - min_radius) * np.sqrt( |
57 | 62 | (values_array - values_array.min()) / (values_array.max() - values_array.min()) |
58 | 63 | ) |
59 | 64 |
|
60 | | -# Circle packing using physics simulation |
61 | | -n = len(labels) |
62 | | - |
63 | | -# Initial positions in grid |
64 | | -grid_size = int(np.ceil(np.sqrt(n))) |
65 | | -positions = np.zeros((n, 2)) |
66 | | -for i in range(n): |
67 | | - positions[i] = [(i % grid_size) * 4 - grid_size * 2, (i // grid_size) * 4 - grid_size * 2] |
68 | | - |
69 | 65 | # Sort by size (largest first) for better packing |
| 66 | +n = len(labels) |
70 | 67 | order = np.argsort(-radii) |
71 | | -positions = positions[order] |
72 | 68 | radii_sorted = radii[order] |
73 | 69 | labels_sorted = [labels[i] for i in order] |
74 | 70 | values_sorted = [values[i] for i in order] |
75 | 71 | colors_sorted = [colors[i] for i in order] |
| 72 | +groups_sorted = [group_map[labels[i]] for i in order] |
76 | 73 |
|
77 | | -# Physics simulation for packing |
78 | | -for iteration in range(350): |
79 | | - # Pull toward center with decreasing strength |
80 | | - pull_strength = 0.06 * (1 - iteration / 400) |
| 74 | +# Assign group IDs for clustering |
| 75 | +unique_groups = list(group_colors.keys()) |
| 76 | +group_ids = np.array([unique_groups.index(g) for g in groups_sorted]) |
| 77 | + |
| 78 | +# Initial positions in spiral pattern for tighter convergence |
| 79 | +angles = np.linspace(0, 4 * np.pi, n) |
| 80 | +spiral_r = np.linspace(0, 3, n) |
| 81 | +positions = np.column_stack([spiral_r * np.cos(angles), spiral_r * np.sin(angles)]) |
| 82 | + |
| 83 | +# Physics simulation with group-aware clustering |
| 84 | +for iteration in range(500): |
| 85 | + progress = iteration / 500 |
| 86 | + pull_strength = 0.06 * (1 - progress * 0.8) |
| 87 | + group_pull = 0.04 * (1 - progress * 0.5) |
| 88 | + |
| 89 | + # Compute group centers of mass |
| 90 | + group_centers = {} |
| 91 | + for gid in range(len(unique_groups)): |
| 92 | + mask = group_ids == gid |
| 93 | + if np.any(mask): |
| 94 | + group_centers[gid] = positions[mask].mean(axis=0) |
| 95 | + |
| 96 | + # Pull toward center + pull toward own group center |
81 | 97 | for i in range(n): |
82 | | - dist = np.sqrt(positions[i, 0] ** 2 + positions[i, 1] ** 2) |
| 98 | + dist = np.linalg.norm(positions[i]) |
83 | 99 | if dist > 0.01: |
84 | 100 | positions[i] -= pull_strength * positions[i] / dist |
85 | 101 |
|
| 102 | + gc = group_centers[group_ids[i]] |
| 103 | + to_group = gc - positions[i] |
| 104 | + gd = np.linalg.norm(to_group) |
| 105 | + if gd > 0.01: |
| 106 | + positions[i] += group_pull * to_group / gd |
| 107 | + |
86 | 108 | # Push apart overlapping circles |
87 | 109 | for i in range(n): |
88 | 110 | for j in range(i + 1, n): |
89 | | - dx = positions[j, 0] - positions[i, 0] |
90 | | - dy = positions[j, 1] - positions[i, 1] |
91 | | - dist = np.sqrt(dx**2 + dy**2) |
92 | | - min_dist = radii_sorted[i] + radii_sorted[j] + 0.05 # Small gap between circles |
| 111 | + delta = positions[j] - positions[i] |
| 112 | + dist = np.linalg.norm(delta) |
| 113 | + same_group = group_ids[i] == group_ids[j] |
| 114 | + gap = 0.04 if same_group else 0.15 |
| 115 | + min_dist = radii_sorted[i] + radii_sorted[j] + gap |
93 | 116 |
|
94 | 117 | if dist < min_dist and dist > 0.001: |
95 | 118 | overlap = (min_dist - dist) / 2 |
96 | | - dx_norm = dx / dist |
97 | | - dy_norm = dy / dist |
98 | | - positions[i, 0] -= overlap * dx_norm |
99 | | - positions[i, 1] -= overlap * dy_norm |
100 | | - positions[j, 0] += overlap * dx_norm |
101 | | - positions[j, 1] += overlap * dy_norm |
102 | | - |
103 | | -# Create plot (4800x2700 px at 300 dpi) |
| 119 | + direction = delta / dist |
| 120 | + positions[i] -= overlap * direction |
| 121 | + positions[j] += overlap * direction |
| 122 | + |
| 123 | +# Center the layout: shift all positions so the bounding box is centered at origin |
| 124 | +bbox_min = positions.min(axis=0) - radii_sorted.max() |
| 125 | +bbox_max = positions.max(axis=0) + radii_sorted.max() |
| 126 | +positions -= (bbox_min + bbox_max) / 2 |
| 127 | + |
| 128 | +# Plot (4800x2700 px at 300 dpi) |
104 | 129 | fig, ax = plt.subplots(figsize=(16, 9)) |
105 | 130 |
|
106 | | -# Draw circles |
| 131 | +# Draw circles using PatchCollection for efficient rendering |
| 132 | +circles = [] |
| 133 | +face_colors = [] |
107 | 134 | for i in range(n): |
108 | | - circle = mpatches.Circle( |
109 | | - (positions[i, 0], positions[i, 1]), |
110 | | - radii_sorted[i], |
111 | | - facecolor=colors_sorted[i], |
112 | | - edgecolor="white", |
113 | | - linewidth=2.5, |
114 | | - alpha=0.88, |
115 | | - ) |
116 | | - ax.add_patch(circle) |
| 135 | + circle = mpatches.Circle((positions[i, 0], positions[i, 1]), radii_sorted[i]) |
| 136 | + circles.append(circle) |
| 137 | + face_colors.append(colors_sorted[i]) |
117 | 138 |
|
118 | | - # Add labels inside larger circles |
119 | | - label_len = len(labels_sorted[i]) |
120 | | - min_radius_for_label = 0.55 + label_len * 0.025 |
121 | | - if radii_sorted[i] > min_radius_for_label: |
| 139 | +collection = mcoll.PatchCollection( |
| 140 | + circles, facecolors=face_colors, edgecolors="white", linewidths=2.5, alpha=0.90, zorder=2 |
| 141 | +) |
| 142 | +ax.add_collection(collection) |
| 143 | + |
| 144 | +# Add labels inside circles that are large enough, external labels for small ones |
| 145 | +small_circles = [] |
| 146 | +for i in range(n): |
| 147 | + label_chars = len(labels_sorted[i]) |
| 148 | + min_r_for_label = 0.48 + label_chars * 0.018 |
| 149 | + if radii_sorted[i] > min_r_for_label: |
122 | 150 | font_scale = min(1.0, radii_sorted[i] / 1.4) |
123 | | - label_fontsize = max(9, int(15 * font_scale)) |
124 | | - value_fontsize = max(8, int(13 * font_scale)) |
| 151 | + label_fontsize = max(12, int(15 * font_scale)) |
| 152 | + value_fontsize = max(12, int(13 * font_scale)) |
| 153 | + |
| 154 | + # Determine text color based on background luminance (WCAG relative luminance) |
| 155 | + bg_color = colors_sorted[i] |
| 156 | + rgb = [int(bg_color[j : j + 2], 16) / 255 for j in (1, 3, 5)] |
| 157 | + luminance = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] |
| 158 | + text_color = "#1a1a2e" if luminance > 0.45 else "white" |
| 159 | + text_outline = ( |
| 160 | + pe.withStroke(linewidth=3, foreground="#00000033") |
| 161 | + if luminance <= 0.45 |
| 162 | + else pe.withStroke(linewidth=3, foreground="#ffffff33") |
| 163 | + ) |
| 164 | + |
| 165 | + # Wrap long labels for smaller circles |
| 166 | + display_label = labels_sorted[i] |
| 167 | + is_wrapped = False |
| 168 | + if " " in display_label and radii_sorted[i] < 1.0: |
| 169 | + display_label = display_label.replace(" ", "\n") |
| 170 | + is_wrapped = True |
| 171 | + |
| 172 | + # Adjust vertical offsets for wrapped vs single-line labels |
| 173 | + label_y_offset = 0.05 if is_wrapped else 0.12 |
| 174 | + value_y_offset = -0.35 if is_wrapped else -0.22 |
| 175 | + |
125 | 176 | ax.text( |
126 | 177 | positions[i, 0], |
127 | | - positions[i, 1] + radii_sorted[i] * 0.1, |
128 | | - labels_sorted[i], |
| 178 | + positions[i, 1] + radii_sorted[i] * label_y_offset, |
| 179 | + display_label, |
129 | 180 | ha="center", |
130 | 181 | va="center", |
131 | 182 | fontsize=label_fontsize, |
132 | 183 | fontweight="bold", |
133 | | - color="white", |
| 184 | + color=text_color, |
| 185 | + path_effects=[text_outline], |
| 186 | + zorder=3, |
134 | 187 | ) |
135 | 188 | ax.text( |
136 | 189 | positions[i, 0], |
137 | | - positions[i, 1] - radii_sorted[i] * 0.22, |
| 190 | + positions[i, 1] + radii_sorted[i] * value_y_offset, |
138 | 191 | f"${values_sorted[i]}K", |
139 | 192 | ha="center", |
140 | 193 | va="center", |
141 | 194 | fontsize=value_fontsize, |
142 | | - color="white", |
143 | | - alpha=0.95, |
| 195 | + color=text_color, |
| 196 | + alpha=0.85, |
| 197 | + path_effects=[text_outline], |
| 198 | + zorder=3, |
144 | 199 | ) |
| 200 | + else: |
| 201 | + small_circles.append(i) |
145 | 202 |
|
146 | | -# Set axis limits with padding |
| 203 | +# External labels with leader lines for small circles |
| 204 | +for i in small_circles: |
| 205 | + cx, cy = positions[i, 0], positions[i, 1] |
| 206 | + r = radii_sorted[i] |
| 207 | + |
| 208 | + # Find direction away from center for label placement |
| 209 | + angle = np.arctan2(cy, cx) |
| 210 | + offset_dist = r + 0.6 |
| 211 | + lx = cx + offset_dist * np.cos(angle) |
| 212 | + ly = cy + offset_dist * np.sin(angle) |
| 213 | + |
| 214 | + ax.annotate( |
| 215 | + f"{labels_sorted[i]}\n${values_sorted[i]}K", |
| 216 | + xy=(cx, cy), |
| 217 | + xytext=(lx, ly), |
| 218 | + fontsize=12, |
| 219 | + fontweight="bold", |
| 220 | + color="#333333", |
| 221 | + ha="center", |
| 222 | + va="center", |
| 223 | + arrowprops={"arrowstyle": "-", "color": "#666666", "lw": 1.2, "shrinkA": 0, "shrinkB": 2}, |
| 224 | + zorder=4, |
| 225 | + ) |
| 226 | + |
| 227 | +# Axis limits with padding |
147 | 228 | all_x = positions[:, 0] |
148 | 229 | all_y = positions[:, 1] |
149 | 230 | max_r = radii_sorted.max() |
150 | | -padding = 0.6 |
| 231 | +padding = 0.9 |
151 | 232 | ax.set_xlim(all_x.min() - max_r - padding, all_x.max() + max_r + padding) |
152 | 233 | ax.set_ylim(all_y.min() - max_r - padding, all_y.max() + max_r + padding) |
153 | 234 | ax.set_aspect("equal") |
154 | | - |
155 | | -# Remove axes for clean visualization |
156 | 235 | ax.axis("off") |
157 | 236 |
|
158 | | -# Title |
| 237 | +# Title with total budget subtitle for context |
| 238 | +total_budget = sum(values) |
159 | 239 | ax.set_title( |
160 | | - "Department Budget Allocation · bubble-packed · matplotlib · pyplots.ai", fontsize=24, fontweight="bold", pad=20 |
| 240 | + f"Department Budget Allocation (${total_budget / 1000:.1f}M Total)\nbubble-packed · matplotlib · pyplots.ai", |
| 241 | + fontsize=24, |
| 242 | + fontweight="bold", |
| 243 | + pad=20, |
| 244 | +) |
| 245 | + |
| 246 | +# Legend for group colors |
| 247 | +legend_handles = [ |
| 248 | + mpatches.Patch(facecolor=color, edgecolor="white", linewidth=1.5, label=group) |
| 249 | + for group, color in group_colors.items() |
| 250 | +] |
| 251 | +ax.legend( |
| 252 | + handles=legend_handles, |
| 253 | + loc="lower right", |
| 254 | + fontsize=16, |
| 255 | + framealpha=0.9, |
| 256 | + edgecolor="#cccccc", |
| 257 | + fancybox=True, |
| 258 | + borderpad=0.8, |
| 259 | + handlelength=1.5, |
| 260 | + handleheight=1.2, |
161 | 261 | ) |
162 | 262 |
|
163 | 263 | plt.tight_layout() |
|
0 commit comments