|
1 | 1 | """ pyplots.ai |
2 | 2 | bubble-packed: Basic Packed Bubble Chart |
3 | | -Library: plotnine 0.15.2 | Python 3.13.11 |
4 | | -Quality: 90/100 | Created: 2025-12-23 |
| 3 | +Library: plotnine 0.15.3 | Python 3.14.3 |
| 4 | +Quality: 90/100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
8 | 8 | import pandas as pd |
9 | 9 | from plotnine import ( |
10 | 10 | aes, |
11 | 11 | coord_fixed, |
12 | | - element_blank, |
| 12 | + element_rect, |
13 | 13 | element_text, |
14 | 14 | geom_polygon, |
15 | 15 | geom_text, |
16 | 16 | ggplot, |
| 17 | + guides, |
17 | 18 | labs, |
| 19 | + scale_alpha_manual, |
18 | 20 | scale_fill_manual, |
19 | 21 | theme, |
20 | 22 | theme_void, |
21 | 23 | ) |
22 | 24 |
|
23 | 25 |
|
24 | 26 | # Data - department budgets (in millions) |
25 | | -np.random.seed(42) |
26 | | -data = { |
| 27 | +departments = { |
27 | 28 | "label": [ |
28 | 29 | "Engineering", |
29 | 30 | "Marketing", |
|
34 | 35 | "R&D", |
35 | 36 | "IT Support", |
36 | 37 | "Legal", |
37 | | - "Customer Service", |
| 38 | + "Customer Svc", |
38 | 39 | "Design", |
39 | 40 | "Logistics", |
40 | 41 | "Quality", |
|
61 | 62 | ], |
62 | 63 | } |
63 | 64 |
|
64 | | -df = pd.DataFrame(data) |
| 65 | +df = pd.DataFrame(departments) |
65 | 66 |
|
66 | 67 | # Scale values to radii (area-based scaling for accurate perception) |
67 | 68 | max_radius = 1.0 |
68 | | -min_radius = 0.3 |
| 69 | +min_radius = 0.25 |
69 | 70 | df["radius"] = min_radius + (max_radius - min_radius) * np.sqrt(df["value"] / df["value"].max()) |
70 | 71 |
|
71 | | -# Circle packing using force simulation (inline, KISS style) |
| 72 | +# Circle packing - greedy placement with vectorized collision detection |
72 | 73 | n = len(df) |
73 | 74 | radii = df["radius"].values |
74 | | - |
75 | | -# Sort by size (largest first) for better packing |
76 | 75 | idx = np.argsort(-radii) |
77 | 76 | sorted_radii = radii[idx] |
| 77 | +gap = 0.03 |
78 | 78 |
|
79 | | -# Initialize positions |
80 | 79 | x = np.zeros(n) |
81 | 80 | y = np.zeros(n) |
| 81 | +angles_sweep = np.linspace(0, 2 * np.pi, 72, endpoint=False) |
82 | 82 |
|
83 | | -# Place circles using greedy algorithm |
84 | 83 | for i in range(1, n): |
85 | 84 | best_dist = float("inf") |
86 | 85 | best_x, best_y = 0.0, 0.0 |
87 | | - |
88 | | - for angle in np.linspace(0, 2 * np.pi, 36): |
89 | | - for ref in range(i): |
90 | | - # Try placing next to reference circle |
91 | | - test_x = x[ref] + (sorted_radii[ref] + sorted_radii[i] + 0.05) * np.cos(angle) |
92 | | - test_y = y[ref] + (sorted_radii[ref] + sorted_radii[i] + 0.05) * np.sin(angle) |
93 | | - |
94 | | - # Check for collisions |
95 | | - valid = True |
96 | | - for j in range(i): |
97 | | - dist = np.sqrt((test_x - x[j]) ** 2 + (test_y - y[j]) ** 2) |
98 | | - if dist < sorted_radii[i] + sorted_radii[j] + 0.03: |
99 | | - valid = False |
100 | | - break |
101 | | - |
102 | | - if valid: |
103 | | - center_dist = np.sqrt(test_x**2 + test_y**2) |
104 | | - if center_dist < best_dist: |
105 | | - best_dist = center_dist |
106 | | - best_x, best_y = test_x, test_y |
| 86 | + target_r = sorted_radii[i] |
| 87 | + |
| 88 | + for ref in range(i): |
| 89 | + place_r = sorted_radii[ref] + target_r + gap |
| 90 | + cx = x[ref] + place_r * np.cos(angles_sweep) |
| 91 | + cy = y[ref] + place_r * np.sin(angles_sweep) |
| 92 | + |
| 93 | + # Vectorized collision check across all angles simultaneously |
| 94 | + dx_c = cx[:, np.newaxis] - x[:i][np.newaxis, :] |
| 95 | + dy_c = cy[:, np.newaxis] - y[:i][np.newaxis, :] |
| 96 | + dists_c = np.hypot(dx_c, dy_c) |
| 97 | + valid = np.all(dists_c >= target_r + sorted_radii[:i] + gap, axis=1) |
| 98 | + |
| 99 | + center_dists = cx**2 + cy**2 |
| 100 | + valid_dists = np.where(valid, center_dists, float("inf")) |
| 101 | + best_k = np.argmin(valid_dists) |
| 102 | + if valid_dists[best_k] < best_dist: |
| 103 | + best_dist = valid_dists[best_k] |
| 104 | + best_x, best_y = cx[best_k], cy[best_k] |
107 | 105 |
|
108 | 106 | x[i] = best_x |
109 | 107 | y[i] = best_y |
110 | 108 |
|
111 | | -# Force simulation to tighten packing |
112 | | -for _ in range(1000): |
113 | | - # Move toward center |
114 | | - x -= x * 0.001 |
115 | | - y -= y * 0.001 |
116 | | - |
117 | | - # Separate overlapping circles |
118 | | - for i in range(n): |
119 | | - for j in range(i + 1, n): |
120 | | - dx = x[j] - x[i] |
121 | | - dy = y[j] - y[i] |
122 | | - dist = np.sqrt(dx * dx + dy * dy) |
123 | | - min_dist = sorted_radii[i] + sorted_radii[j] + 0.03 |
124 | | - |
125 | | - if dist < min_dist and dist > 0.001: |
126 | | - overlap = (min_dist - dist) / 2 |
127 | | - dx_norm = dx / dist |
128 | | - dy_norm = dy / dist |
129 | | - x[i] -= overlap * dx_norm * 0.5 |
130 | | - y[i] -= overlap * dy_norm * 0.5 |
131 | | - x[j] += overlap * dx_norm * 0.5 |
132 | | - y[j] += overlap * dy_norm * 0.5 |
| 109 | +# Force simulation to tighten packing (vectorized with numpy) |
| 110 | +tri = np.triu(np.ones((n, n), dtype=bool), k=1) |
| 111 | +min_dists = sorted_radii[:, np.newaxis] + sorted_radii[np.newaxis, :] + gap |
| 112 | + |
| 113 | +for _ in range(2000): |
| 114 | + x *= 0.997 |
| 115 | + y *= 0.997 |
| 116 | + |
| 117 | + dx = x[:, np.newaxis] - x[np.newaxis, :] |
| 118 | + dy = y[:, np.newaxis] - y[np.newaxis, :] |
| 119 | + dists = np.hypot(dx, dy) |
| 120 | + |
| 121 | + overlap = tri & (dists < min_dists) & (dists > 1e-3) |
| 122 | + if overlap.any(): |
| 123 | + safe_dists = np.where(dists > 1e-3, dists, 1.0) |
| 124 | + push = ((min_dists - dists) / (2 * safe_dists)) * overlap |
| 125 | + corr_x = push * dx |
| 126 | + corr_y = push * dy |
| 127 | + x += corr_x.sum(axis=1) - corr_x.sum(axis=0) |
| 128 | + y += corr_y.sum(axis=1) - corr_y.sum(axis=0) |
133 | 129 |
|
134 | 130 | # Restore original order |
135 | | -x_out = np.zeros(n) |
136 | | -y_out = np.zeros(n) |
| 131 | +x_final = np.zeros(n) |
| 132 | +y_final = np.zeros(n) |
137 | 133 | for i, orig_idx in enumerate(idx): |
138 | | - x_out[orig_idx] = x[i] |
139 | | - y_out[orig_idx] = y[i] |
| 134 | + x_final[orig_idx] = x[i] |
| 135 | + y_final[orig_idx] = y[i] |
140 | 136 |
|
141 | | -df["x"] = x_out |
142 | | -df["y"] = y_out |
| 137 | +df["x"] = x_final |
| 138 | +df["y"] = y_final |
143 | 139 |
|
144 | | -# Create circle polygons for geom_polygon |
| 140 | +# Build circle polygons for geom_polygon |
145 | 141 | circle_dfs = [] |
| 142 | +angles = np.linspace(0, 2 * np.pi, 64) |
146 | 143 | for i, row in df.iterrows(): |
147 | | - angles = np.linspace(0, 2 * np.pi, 64) |
148 | 144 | cx = row["x"] + row["radius"] * np.cos(angles) |
149 | 145 | cy = row["y"] + row["radius"] * np.sin(angles) |
150 | | - circle_df = pd.DataFrame({"x": cx, "y": cy, "label": row["label"], "group": row["group"], "circle_id": i}) |
151 | | - circle_dfs.append(circle_df) |
152 | | - |
| 146 | + circle_dfs.append(pd.DataFrame({"x": cx, "y": cy, "label": row["label"], "group": row["group"], "circle_id": i})) |
153 | 147 | circles_df = pd.concat(circle_dfs, ignore_index=True) |
| 148 | +circles_df["group"] = pd.Categorical(circles_df["group"], categories=["Tech", "Business", "Operations", "Support"]) |
154 | 149 |
|
155 | | -# Color palette for groups - colorblind-safe (Okabe-Ito palette) |
156 | | -group_colors = { |
157 | | - "Tech": "#0072B2", # Blue |
158 | | - "Business": "#E69F00", # Orange |
159 | | - "Operations": "#009E73", # Bluish Green |
160 | | - "Support": "#CC79A7", # Reddish Purple |
161 | | -} |
162 | | - |
163 | | -# Create label dataframe (centers) - show full labels for circles large enough |
164 | | -labels_df = df[["x", "y", "label", "radius"]].copy() |
165 | | - |
166 | | -# Show full label for large circles, abbreviated for medium, none for small |
| 150 | +# Labels - conditional sizing: full name for large, abbreviated for small |
| 151 | +labels_df = df.copy() |
167 | 152 | labels_df["display_label"] = labels_df.apply( |
168 | 153 | lambda row: ( |
169 | | - row["label"] |
170 | | - if row["radius"] >= 0.85 |
171 | | - else ( |
172 | | - (row["label"][:8] if len(row["label"]) > 8 else row["label"]) |
173 | | - if row["radius"] >= 0.6 |
174 | | - else ((row["label"][:5] if len(row["label"]) > 5 else row["label"]) if row["radius"] >= 0.45 else "") |
175 | | - ) |
| 154 | + row["label"] if row["value"] >= 22 else (row["label"].split()[0] if row["value"] >= 10 else row["label"][:4]) |
176 | 155 | ), |
177 | 156 | axis=1, |
178 | 157 | ) |
| 158 | +labels_df["value_label"] = labels_df["value"].apply(lambda v: f"${v}M") |
179 | 159 |
|
180 | | -# Create plot |
| 160 | +# Alpha by group emphasis — Tech & Business slightly more prominent |
| 161 | +alpha_values = {"Tech": 0.90, "Business": 0.85, "Operations": 0.78, "Support": 0.75} |
| 162 | + |
| 163 | +# Color palette - Okabe-Ito colorblind-safe |
| 164 | +group_colors = {"Tech": "#0072B2", "Business": "#E69F00", "Operations": "#009E73", "Support": "#CC79A7"} |
| 165 | + |
| 166 | +# Compute group totals for subtitle |
| 167 | +group_totals = df.groupby("group")["value"].sum() |
| 168 | +subtitle_text = " \u00b7 ".join(f"{g}: \\${group_totals[g]}M" for g in ["Tech", "Business", "Operations", "Support"]) |
| 169 | + |
| 170 | +# Tight viewport bounds for optimal canvas utilization |
| 171 | +pad = 0.15 |
| 172 | +x_lo = (df["x"] - df["radius"]).min() - pad |
| 173 | +x_hi = (df["x"] + df["radius"]).max() + pad |
| 174 | +y_lo = (df["y"] - df["radius"]).min() - pad |
| 175 | +y_hi = (df["y"] + df["radius"]).max() + pad |
| 176 | +half_span = max(x_hi - x_lo, y_hi - y_lo) / 2 |
| 177 | +cx_mid, cy_mid = (x_lo + x_hi) / 2, (y_lo + y_hi) / 2 |
| 178 | + |
| 179 | +# Plot with layered grammar of graphics composition |
181 | 180 | plot = ( |
182 | 181 | ggplot() |
| 182 | + # Layer 1: Circle fills with group-specific alpha |
183 | 183 | + geom_polygon( |
184 | | - data=circles_df, mapping=aes(x="x", y="y", fill="group", group="circle_id"), color="white", size=0.5, alpha=0.85 |
| 184 | + data=circles_df, |
| 185 | + mapping=aes(x="x", y="y", fill="group", group="circle_id", alpha="group"), |
| 186 | + color="white", |
| 187 | + size=0.8, |
| 188 | + ) |
| 189 | + # Layer 2: Department name labels (bold, white) |
| 190 | + + geom_text( |
| 191 | + data=labels_df[labels_df["value"] >= 10], |
| 192 | + mapping=aes(x="x", y="y", label="display_label"), |
| 193 | + size=12, |
| 194 | + color="white", |
| 195 | + fontweight="bold", |
| 196 | + nudge_y=0.10, |
| 197 | + ) |
| 198 | + # Layer 3: Small bubble labels |
| 199 | + + geom_text( |
| 200 | + data=labels_df[labels_df["value"] < 10], |
| 201 | + mapping=aes(x="x", y="y", label="display_label"), |
| 202 | + size=10, |
| 203 | + color="white", |
| 204 | + fontweight="bold", |
185 | 205 | ) |
| 206 | + # Layer 4: Budget value annotations for large/medium bubbles |
186 | 207 | + geom_text( |
187 | | - data=labels_df, mapping=aes(x="x", y="y", label="display_label"), size=9, color="white", fontweight="bold" |
| 208 | + data=labels_df[labels_df["value"] >= 12], |
| 209 | + mapping=aes(x="x", y="y", label="value_label"), |
| 210 | + size=10, |
| 211 | + color="white", |
| 212 | + alpha=0.85, |
| 213 | + nudge_y=-0.17, |
188 | 214 | ) |
189 | | - + scale_fill_manual(values=group_colors) |
190 | | - + coord_fixed() |
191 | | - + labs(title="bubble-packed · plotnine · pyplots.ai", fill="Department Group") |
| 215 | + # Scales |
| 216 | + + scale_fill_manual(values=group_colors, name="Department Group") |
| 217 | + + scale_alpha_manual(values=alpha_values) |
| 218 | + + guides(alpha=False) |
| 219 | + # Tight viewport with coord_fixed for 1:1 aspect ratio |
| 220 | + + coord_fixed(xlim=(cx_mid - half_span, cx_mid + half_span), ylim=(cy_mid - half_span, cy_mid + half_span)) |
| 221 | + + labs(title="bubble-packed \u00b7 plotnine \u00b7 pyplots.ai", subtitle=subtitle_text) |
| 222 | + # Theme — plotnine's distinctive void theme with layered customization |
192 | 223 | + theme_void() |
193 | 224 | + theme( |
194 | | - figure_size=(16, 9), |
195 | | - plot_title=element_text(size=24, ha="center", weight="bold"), |
196 | | - legend_title=element_text(size=18), |
197 | | - legend_text=element_text(size=14), |
198 | | - legend_position="right", |
199 | | - plot_background=element_blank(), |
| 225 | + figure_size=(12, 12), |
| 226 | + plot_title=element_text(size=24, ha="center", weight="bold", margin={"b": 5}), |
| 227 | + plot_subtitle=element_text(size=16, ha="center", color="#555555", margin={"t": 5, "b": 10}), |
| 228 | + legend_title=element_text(size=18, weight="bold"), |
| 229 | + legend_text=element_text(size=16), |
| 230 | + legend_position="bottom", |
| 231 | + legend_direction="horizontal", |
| 232 | + legend_key=element_rect(fill="white", color="none"), |
| 233 | + legend_key_size=20, |
| 234 | + plot_background=element_rect(fill="white", color="none"), |
| 235 | + plot_margin=0.02, |
200 | 236 | ) |
201 | 237 | ) |
202 | 238 |
|
|
0 commit comments