|
1 | | -""" pyplots.ai |
| 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: /100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import matplotlib.collections as mcoll |
7 | 8 | import matplotlib.patches as mpatches |
8 | 9 | import matplotlib.pyplot as plt |
9 | 10 | import numpy as np |
10 | 11 |
|
11 | 12 |
|
12 | 13 | # Data - Department budget allocation (in thousands) |
13 | | -np.random.seed(42) |
14 | 14 | labels = [ |
15 | 15 | "Engineering", |
16 | 16 | "Marketing", |
|
28 | 28 | "Security", |
29 | 29 | "QA", |
30 | 30 | ] |
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 | | -] |
| 31 | +values = [950, 420, 680, 310, 160, 280, 820, 200, 130, 370, 230, 580, 470, 145, 175] |
| 32 | + |
| 33 | +# Group assignments and colors (colorblind-safe palette) |
| 34 | +group_map = { |
| 35 | + "Engineering": "Tech", |
| 36 | + "IT": "Tech", |
| 37 | + "Product": "Tech", |
| 38 | + "Sales": "Tech", |
| 39 | + "Marketing": "Creative", |
| 40 | + "R&D": "Creative", |
| 41 | + "Design": "Creative", |
| 42 | + "Data Science": "Creative", |
| 43 | + "Operations": "Support", |
| 44 | + "HR": "Support", |
| 45 | + "Finance": "Support", |
| 46 | + "Customer Support": "Support", |
| 47 | + "Legal": "Compliance", |
| 48 | + "Security": "Compliance", |
| 49 | + "QA": "Compliance", |
| 50 | +} |
| 51 | +group_colors = {"Tech": "#306998", "Creative": "#FFD43B", "Support": "#4A90A4", "Compliance": "#7B9E89"} |
| 52 | +colors = [group_colors[group_map[label]] for label in labels] |
51 | 53 |
|
52 | 54 | # 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) |
| 55 | +min_radius = 0.30 |
| 56 | +max_radius = 2.0 |
| 57 | +values_array = np.array(values, dtype=float) |
56 | 58 | radii = min_radius + (max_radius - min_radius) * np.sqrt( |
57 | 59 | (values_array - values_array.min()) / (values_array.max() - values_array.min()) |
58 | 60 | ) |
59 | 61 |
|
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 | 62 | # Sort by size (largest first) for better packing |
| 63 | +n = len(labels) |
70 | 64 | order = np.argsort(-radii) |
71 | | -positions = positions[order] |
72 | 65 | radii_sorted = radii[order] |
73 | 66 | labels_sorted = [labels[i] for i in order] |
74 | 67 | values_sorted = [values[i] for i in order] |
75 | 68 | colors_sorted = [colors[i] for i in order] |
76 | 69 |
|
| 70 | +# Initial positions in spiral pattern for tighter convergence |
| 71 | +angles = np.linspace(0, 4 * np.pi, n) |
| 72 | +spiral_r = np.linspace(0, 3, n) |
| 73 | +positions = np.column_stack([spiral_r * np.cos(angles), spiral_r * np.sin(angles)]) |
| 74 | + |
77 | 75 | # 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) |
| 76 | +for iteration in range(400): |
| 77 | + pull_strength = 0.07 * (1 - iteration / 450) |
| 78 | + |
| 79 | + # Pull toward center |
81 | 80 | for i in range(n): |
82 | | - dist = np.sqrt(positions[i, 0] ** 2 + positions[i, 1] ** 2) |
| 81 | + dist = np.linalg.norm(positions[i]) |
83 | 82 | if dist > 0.01: |
84 | 83 | positions[i] -= pull_strength * positions[i] / dist |
85 | 84 |
|
86 | 85 | # Push apart overlapping circles |
87 | 86 | for i in range(n): |
88 | 87 | 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 |
| 88 | + delta = positions[j] - positions[i] |
| 89 | + dist = np.linalg.norm(delta) |
| 90 | + min_dist = radii_sorted[i] + radii_sorted[j] + 0.06 |
93 | 91 |
|
94 | 92 | if dist < min_dist and dist > 0.001: |
95 | 93 | 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) |
| 94 | + direction = delta / dist |
| 95 | + positions[i] -= overlap * direction |
| 96 | + positions[j] += overlap * direction |
| 97 | + |
| 98 | +# Plot (4800x2700 px at 300 dpi) |
104 | 99 | fig, ax = plt.subplots(figsize=(16, 9)) |
105 | 100 |
|
106 | | -# Draw circles |
| 101 | +# Draw circles using PatchCollection for efficient rendering |
| 102 | +circles = [] |
| 103 | +face_colors = [] |
107 | 104 | 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) |
117 | | - |
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: |
| 105 | + circle = mpatches.Circle((positions[i, 0], positions[i, 1]), radii_sorted[i]) |
| 106 | + circles.append(circle) |
| 107 | + face_colors.append(colors_sorted[i]) |
| 108 | + |
| 109 | +collection = mcoll.PatchCollection( |
| 110 | + circles, facecolors=face_colors, edgecolors="white", linewidths=2.5, alpha=0.90, zorder=2 |
| 111 | +) |
| 112 | +ax.add_collection(collection) |
| 113 | + |
| 114 | +# Add labels inside circles that are large enough |
| 115 | +for i in range(n): |
| 116 | + label_chars = len(labels_sorted[i]) |
| 117 | + min_r_for_label = 0.48 + label_chars * 0.018 |
| 118 | + if radii_sorted[i] > min_r_for_label: |
122 | 119 | font_scale = min(1.0, radii_sorted[i] / 1.4) |
123 | 120 | label_fontsize = max(9, int(15 * font_scale)) |
124 | 121 | value_fontsize = max(8, int(13 * font_scale)) |
| 122 | + |
| 123 | + # Determine text color based on background brightness |
| 124 | + bg_color = colors_sorted[i] |
| 125 | + text_color = "#1a1a2e" if bg_color == "#FFD43B" else "white" |
| 126 | + |
| 127 | + # Wrap long labels for smaller circles |
| 128 | + display_label = labels_sorted[i] |
| 129 | + is_wrapped = False |
| 130 | + if " " in display_label and radii_sorted[i] < 1.0: |
| 131 | + display_label = display_label.replace(" ", "\n") |
| 132 | + is_wrapped = True |
| 133 | + |
| 134 | + # Adjust vertical offsets for wrapped vs single-line labels |
| 135 | + label_y_offset = 0.05 if is_wrapped else 0.12 |
| 136 | + value_y_offset = -0.35 if is_wrapped else -0.22 |
| 137 | + |
125 | 138 | ax.text( |
126 | 139 | positions[i, 0], |
127 | | - positions[i, 1] + radii_sorted[i] * 0.1, |
128 | | - labels_sorted[i], |
| 140 | + positions[i, 1] + radii_sorted[i] * label_y_offset, |
| 141 | + display_label, |
129 | 142 | ha="center", |
130 | 143 | va="center", |
131 | 144 | fontsize=label_fontsize, |
132 | 145 | fontweight="bold", |
133 | | - color="white", |
| 146 | + color=text_color, |
| 147 | + zorder=3, |
134 | 148 | ) |
135 | 149 | ax.text( |
136 | 150 | positions[i, 0], |
137 | | - positions[i, 1] - radii_sorted[i] * 0.22, |
| 151 | + positions[i, 1] + radii_sorted[i] * value_y_offset, |
138 | 152 | f"${values_sorted[i]}K", |
139 | 153 | ha="center", |
140 | 154 | va="center", |
141 | 155 | fontsize=value_fontsize, |
142 | | - color="white", |
143 | | - alpha=0.95, |
| 156 | + color=text_color, |
| 157 | + alpha=0.85, |
| 158 | + zorder=3, |
144 | 159 | ) |
145 | 160 |
|
146 | | -# Set axis limits with padding |
| 161 | +# Axis limits with padding |
147 | 162 | all_x = positions[:, 0] |
148 | 163 | all_y = positions[:, 1] |
149 | 164 | max_r = radii_sorted.max() |
150 | | -padding = 0.6 |
| 165 | +padding = 0.8 |
151 | 166 | ax.set_xlim(all_x.min() - max_r - padding, all_x.max() + max_r + padding) |
152 | 167 | ax.set_ylim(all_y.min() - max_r - padding, all_y.max() + max_r + padding) |
153 | 168 | ax.set_aspect("equal") |
154 | | - |
155 | | -# Remove axes for clean visualization |
156 | 169 | ax.axis("off") |
157 | 170 |
|
158 | 171 | # Title |
159 | 172 | ax.set_title( |
160 | 173 | "Department Budget Allocation · bubble-packed · matplotlib · pyplots.ai", fontsize=24, fontweight="bold", pad=20 |
161 | 174 | ) |
162 | 175 |
|
| 176 | +# Legend for group colors |
| 177 | +legend_handles = [ |
| 178 | + mpatches.Patch(facecolor=color, edgecolor="white", linewidth=1.5, label=group) |
| 179 | + for group, color in group_colors.items() |
| 180 | +] |
| 181 | +ax.legend( |
| 182 | + handles=legend_handles, |
| 183 | + loc="lower right", |
| 184 | + fontsize=16, |
| 185 | + framealpha=0.9, |
| 186 | + edgecolor="#cccccc", |
| 187 | + fancybox=True, |
| 188 | + borderpad=0.8, |
| 189 | + handlelength=1.5, |
| 190 | + handleheight=1.2, |
| 191 | +) |
| 192 | + |
163 | 193 | plt.tight_layout() |
164 | 194 | plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
0 commit comments