|
1 | | -""" pyplots.ai |
| 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: /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, |
18 | 19 | scale_fill_manual, |
19 | 20 | theme, |
|
22 | 23 |
|
23 | 24 |
|
24 | 25 | # Data - department budgets (in millions) |
25 | | -np.random.seed(42) |
26 | | -data = { |
| 26 | +departments = { |
27 | 27 | "label": [ |
28 | 28 | "Engineering", |
29 | 29 | "Marketing", |
|
34 | 34 | "R&D", |
35 | 35 | "IT Support", |
36 | 36 | "Legal", |
37 | | - "Customer Service", |
| 37 | + "Customer Svc", |
38 | 38 | "Design", |
39 | 39 | "Logistics", |
40 | 40 | "Quality", |
|
61 | 61 | ], |
62 | 62 | } |
63 | 63 |
|
64 | | -df = pd.DataFrame(data) |
| 64 | +df = pd.DataFrame(departments) |
65 | 65 |
|
66 | 66 | # Scale values to radii (area-based scaling for accurate perception) |
67 | 67 | max_radius = 1.0 |
68 | | -min_radius = 0.3 |
| 68 | +min_radius = 0.25 |
69 | 69 | df["radius"] = min_radius + (max_radius - min_radius) * np.sqrt(df["value"] / df["value"].max()) |
70 | 70 |
|
71 | | -# Circle packing using force simulation (inline, KISS style) |
| 71 | +# Circle packing - greedy placement + force simulation |
72 | 72 | n = len(df) |
73 | 73 | radii = df["radius"].values |
74 | | - |
75 | | -# Sort by size (largest first) for better packing |
76 | 74 | idx = np.argsort(-radii) |
77 | 75 | sorted_radii = radii[idx] |
| 76 | +gap = 0.03 |
78 | 77 |
|
79 | | -# Initialize positions |
80 | 78 | x = np.zeros(n) |
81 | 79 | y = np.zeros(n) |
82 | 80 |
|
83 | | -# Place circles using greedy algorithm |
84 | 81 | for i in range(1, n): |
85 | 82 | best_dist = float("inf") |
86 | 83 | best_x, best_y = 0.0, 0.0 |
87 | 84 |
|
88 | | - for angle in np.linspace(0, 2 * np.pi, 36): |
| 85 | + for angle in np.linspace(0, 2 * np.pi, 72, endpoint=False): |
89 | 86 | 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) |
| 87 | + test_x = x[ref] + (sorted_radii[ref] + sorted_radii[i] + gap) * np.cos(angle) |
| 88 | + test_y = y[ref] + (sorted_radii[ref] + sorted_radii[i] + gap) * np.sin(angle) |
93 | 89 |
|
94 | | - # Check for collisions |
95 | 90 | valid = True |
96 | 91 | for j in range(i): |
97 | 92 | dist = np.sqrt((test_x - x[j]) ** 2 + (test_y - y[j]) ** 2) |
98 | | - if dist < sorted_radii[i] + sorted_radii[j] + 0.03: |
| 93 | + if dist < sorted_radii[i] + sorted_radii[j] + gap: |
99 | 94 | valid = False |
100 | 95 | break |
101 | 96 |
|
|
109 | 104 | y[i] = best_y |
110 | 105 |
|
111 | 106 | # Force simulation to tighten packing |
112 | | -for _ in range(1000): |
113 | | - # Move toward center |
114 | | - x -= x * 0.001 |
115 | | - y -= y * 0.001 |
| 107 | +for _ in range(2000): |
| 108 | + x -= x * 0.003 |
| 109 | + y -= y * 0.003 |
116 | 110 |
|
117 | | - # Separate overlapping circles |
118 | 111 | for i in range(n): |
119 | 112 | for j in range(i + 1, n): |
120 | 113 | dx = x[j] - x[i] |
121 | 114 | dy = y[j] - y[i] |
122 | 115 | dist = np.sqrt(dx * dx + dy * dy) |
123 | | - min_dist = sorted_radii[i] + sorted_radii[j] + 0.03 |
| 116 | + min_dist = sorted_radii[i] + sorted_radii[j] + gap |
124 | 117 |
|
125 | 118 | if dist < min_dist and dist > 0.001: |
126 | 119 | overlap = (min_dist - dist) / 2 |
127 | 120 | dx_norm = dx / dist |
128 | 121 | 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 |
| 122 | + x[i] -= overlap * dx_norm |
| 123 | + y[i] -= overlap * dy_norm |
| 124 | + x[j] += overlap * dx_norm |
| 125 | + y[j] += overlap * dy_norm |
133 | 126 |
|
134 | 127 | # Restore original order |
135 | | -x_out = np.zeros(n) |
136 | | -y_out = np.zeros(n) |
| 128 | +x_final = np.zeros(n) |
| 129 | +y_final = np.zeros(n) |
137 | 130 | for i, orig_idx in enumerate(idx): |
138 | | - x_out[orig_idx] = x[i] |
139 | | - y_out[orig_idx] = y[i] |
| 131 | + x_final[orig_idx] = x[i] |
| 132 | + y_final[orig_idx] = y[i] |
140 | 133 |
|
141 | | -df["x"] = x_out |
142 | | -df["y"] = y_out |
| 134 | +df["x"] = x_final |
| 135 | +df["y"] = y_final |
143 | 136 |
|
144 | | -# Create circle polygons for geom_polygon |
| 137 | +# Build circle polygons for geom_polygon |
145 | 138 | circle_dfs = [] |
| 139 | +angles = np.linspace(0, 2 * np.pi, 64) |
146 | 140 | for i, row in df.iterrows(): |
147 | | - angles = np.linspace(0, 2 * np.pi, 64) |
148 | 141 | cx = row["x"] + row["radius"] * np.cos(angles) |
149 | 142 | 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 | | - |
| 143 | + circle_dfs.append(pd.DataFrame({"x": cx, "y": cy, "label": row["label"], "group": row["group"], "circle_id": i})) |
153 | 144 | circles_df = pd.concat(circle_dfs, ignore_index=True) |
| 145 | +circles_df["group"] = pd.Categorical(circles_df["group"], categories=["Tech", "Business", "Operations", "Support"]) |
154 | 146 |
|
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 |
| 147 | +# Labels - full name for large, first word for medium, none for small |
| 148 | +labels_df = df.copy() |
167 | 149 | labels_df["display_label"] = labels_df.apply( |
168 | | - 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 | | - ) |
176 | | - ), |
177 | | - axis=1, |
| 150 | + lambda row: row["label"] if row["value"] >= 22 else (row["label"].split()[0] if row["value"] >= 12 else ""), axis=1 |
178 | 151 | ) |
179 | 152 |
|
180 | | -# Create plot |
| 153 | +# Color palette - Okabe-Ito colorblind-safe |
| 154 | +group_colors = {"Tech": "#0072B2", "Business": "#E69F00", "Operations": "#009E73", "Support": "#CC79A7"} |
| 155 | + |
| 156 | +# Plot |
181 | 157 | plot = ( |
182 | 158 | ggplot() |
183 | 159 | + 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 |
| 160 | + data=circles_df, mapping=aes(x="x", y="y", fill="group", group="circle_id"), color="white", size=0.6, alpha=0.85 |
185 | 161 | ) |
186 | 162 | + geom_text( |
187 | 163 | data=labels_df, mapping=aes(x="x", y="y", label="display_label"), size=9, color="white", fontweight="bold" |
188 | 164 | ) |
189 | | - + scale_fill_manual(values=group_colors) |
| 165 | + + scale_fill_manual(values=group_colors, name="Department Group") |
| 166 | + + guides(fill="legend") |
190 | 167 | + coord_fixed() |
191 | | - + labs(title="bubble-packed · plotnine · pyplots.ai", fill="Department Group") |
| 168 | + + labs(title="bubble-packed · plotnine · pyplots.ai") |
192 | 169 | + theme_void() |
193 | 170 | + theme( |
194 | 171 | 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), |
| 172 | + plot_title=element_text(size=24, ha="center", weight="bold", margin={"b": 12}), |
| 173 | + legend_title=element_text(size=18, weight="bold"), |
| 174 | + legend_text=element_text(size=16), |
198 | 175 | legend_position="right", |
199 | | - plot_background=element_blank(), |
| 176 | + legend_key=element_rect(fill="white", color="none"), |
| 177 | + plot_background=element_rect(fill="white", color="none"), |
200 | 178 | ) |
201 | 179 | ) |
202 | 180 |
|
|
0 commit comments