|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | bubble-packed: Basic Packed Bubble Chart |
3 | | -Library: altair 6.0.0 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-23 |
| 3 | +Library: altair 6.0.0 | Python 3.14.3 |
| 4 | +Quality: /100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import altair as alt |
8 | 8 | import numpy as np |
9 | 9 | import pandas as pd |
10 | 10 |
|
11 | 11 |
|
12 | | -# Data - Department budget allocation |
| 12 | +# Data - Department budget allocation with group clusters |
13 | 13 | np.random.seed(42) |
14 | | -data = { |
| 14 | +departments = { |
15 | 15 | "label": [ |
16 | 16 | "Engineering", |
| 17 | + "R&D", |
| 18 | + "Data Science", |
| 19 | + "QA", |
17 | 20 | "Marketing", |
18 | 21 | "Sales", |
19 | | - "Operations", |
20 | | - "HR", |
21 | | - "Finance", |
22 | | - "R&D", |
23 | 22 | "Support", |
| 23 | + "Finance", |
| 24 | + "HR", |
24 | 25 | "Legal", |
| 26 | + "Operations", |
25 | 27 | "IT", |
| 28 | + "Security", |
26 | 29 | "Design", |
27 | 30 | "Product", |
28 | | - "Data Science", |
29 | | - "Security", |
30 | | - "QA", |
31 | 31 | ], |
32 | | - "value": [850, 420, 680, 320, 180, 290, 750, 210, 150, 380, 240, 550, 460, 170, 195], |
| 32 | + "value": [850, 750, 460, 195, 420, 680, 210, 290, 180, 150, 320, 380, 170, 240, 550], |
| 33 | + "group": [ |
| 34 | + "Technology", |
| 35 | + "Technology", |
| 36 | + "Technology", |
| 37 | + "Technology", |
| 38 | + "Revenue", |
| 39 | + "Revenue", |
| 40 | + "Revenue", |
| 41 | + "Corporate", |
| 42 | + "Corporate", |
| 43 | + "Corporate", |
| 44 | + "Operations", |
| 45 | + "Operations", |
| 46 | + "Operations", |
| 47 | + "Product", |
| 48 | + "Product", |
| 49 | + ], |
33 | 50 | } |
34 | 51 |
|
35 | | -labels = data["label"] |
36 | | -values = data["value"] |
| 52 | +labels = departments["label"] |
| 53 | +values = departments["value"] |
| 54 | +groups = departments["group"] |
37 | 55 | n = len(labels) |
38 | 56 |
|
39 | | -# Scale values to radius (using sqrt for area-proportional sizing) |
| 57 | +# Scale values to radius (sqrt for area-proportional sizing) |
40 | 58 | min_radius = 30 |
41 | 59 | max_radius = 120 |
42 | 60 | values_array = np.array(values) |
|
49 | 67 | radii = radii[order] |
50 | 68 | labels = [labels[i] for i in order] |
51 | 69 | values = [values[i] for i in order] |
| 70 | +groups = [groups[i] for i in order] |
52 | 71 |
|
53 | 72 | # Circle packing - place circles one by one, finding best position |
54 | 73 | x_pos = np.zeros(n) |
55 | 74 | y_pos = np.zeros(n) |
56 | 75 |
|
57 | | -# Place first circle at center |
58 | | -x_pos[0] = 0 |
59 | | -y_pos[0] = 0 |
60 | | - |
61 | | -# Place remaining circles |
62 | 76 | for i in range(1, n): |
63 | | - best_x, best_y = 0, 0 |
| 77 | + best_x, best_y = 0.0, 0.0 |
64 | 78 | best_dist = float("inf") |
65 | 79 |
|
66 | | - # Try positions around existing circles |
67 | 80 | for j in range(i): |
68 | 81 | for angle in np.linspace(0, 2 * np.pi, 36, endpoint=False): |
69 | | - # Position touching circle j |
70 | 82 | test_x = x_pos[j] + (radii[j] + radii[i] + 2) * np.cos(angle) |
71 | 83 | test_y = y_pos[j] + (radii[j] + radii[i] + 2) * np.sin(angle) |
72 | 84 |
|
73 | | - # Check for overlaps with all placed circles |
74 | 85 | valid = True |
75 | 86 | for k in range(i): |
76 | 87 | dx = test_x - x_pos[k] |
77 | 88 | dy = test_y - y_pos[k] |
78 | | - dist = np.sqrt(dx**2 + dy**2) |
79 | | - if dist < radii[i] + radii[k] + 1: |
| 89 | + if np.sqrt(dx**2 + dy**2) < radii[i] + radii[k] + 1: |
80 | 90 | valid = False |
81 | 91 | break |
82 | 92 |
|
83 | 93 | if valid: |
84 | | - # Prefer positions closer to center |
85 | 94 | center_dist = np.sqrt(test_x**2 + test_y**2) |
86 | 95 | if center_dist < best_dist: |
87 | 96 | best_dist = center_dist |
|
90 | 99 | x_pos[i] = best_x |
91 | 100 | y_pos[i] = best_y |
92 | 101 |
|
93 | | -# Fine-tune with physics simulation |
| 102 | +# Physics simulation for tighter packing |
94 | 103 | for _ in range(200): |
95 | 104 | for i in range(n): |
96 | | - fx, fy = 0, 0 |
97 | | - # Gentle centering force |
98 | | - fx -= x_pos[i] * 0.01 |
99 | | - fy -= y_pos[i] * 0.01 |
100 | | - # Repulsion from overlapping circles |
| 105 | + fx, fy = -x_pos[i] * 0.01, -y_pos[i] * 0.01 |
101 | 106 | for j in range(n): |
102 | 107 | if i != j: |
103 | 108 | dx = x_pos[i] - x_pos[j] |
|
111 | 116 | x_pos[i] += fx |
112 | 117 | y_pos[i] += fy |
113 | 118 |
|
114 | | -# Color palette - colorblind-safe colors with Python Blue and Yellow as primary |
115 | | -colors_list = ["#306998", "#FFD43B", "#4A90A4", "#7B9E89", "#E07A5F"] |
116 | | -colors = [colors_list[i % len(colors_list)] for i in range(n)] |
| 119 | +# Center the layout |
| 120 | +x_center = (x_pos.min() + x_pos.max()) / 2 |
| 121 | +y_center = (y_pos.min() + y_pos.max()) / 2 |
| 122 | +x_pos -= x_center |
| 123 | +y_pos -= y_center |
| 124 | + |
| 125 | +# Group color palette - colorblind-safe |
| 126 | +group_colors = { |
| 127 | + "Technology": "#306998", |
| 128 | + "Revenue": "#E07A5F", |
| 129 | + "Corporate": "#7B9E89", |
| 130 | + "Operations": "#4A90A4", |
| 131 | + "Product": "#FFD43B", |
| 132 | +} |
117 | 133 |
|
118 | | -# Create DataFrame with computed positions |
119 | 134 | df = pd.DataFrame( |
120 | 135 | { |
121 | 136 | "label": labels, |
122 | 137 | "value": values, |
| 138 | + "group": groups, |
123 | 139 | "x": x_pos, |
124 | 140 | "y": y_pos, |
125 | 141 | "radius": radii, |
126 | | - "color": colors, |
127 | | - "formatted_value": [f"${v}K" for v in values], |
| 142 | + "budget": [f"${v}K" for v in values], |
128 | 143 | } |
129 | 144 | ) |
130 | 145 |
|
131 | | -# Create circles using mark_circle with computed positions |
| 146 | +# Group ordering for consistent legend |
| 147 | +group_order = ["Technology", "Revenue", "Operations", "Corporate", "Product"] |
| 148 | + |
| 149 | +# Circles layer |
132 | 150 | circles = ( |
133 | 151 | alt.Chart(df) |
134 | 152 | .mark_circle(opacity=0.85, stroke="white", strokeWidth=2) |
135 | 153 | .encode( |
136 | | - x=alt.X("x:Q", axis=None), |
137 | | - y=alt.Y("y:Q", axis=None), |
| 154 | + x=alt.X("x:Q", axis=None, scale=alt.Scale(padding=max_radius * 1.4)), |
| 155 | + y=alt.Y("y:Q", axis=None, scale=alt.Scale(padding=max_radius * 1.4)), |
138 | 156 | size=alt.Size("radius:Q", scale=alt.Scale(range=[min_radius**2 * 3, max_radius**2 * 3]), legend=None), |
139 | | - color=alt.Color("color:N", scale=None, legend=None), |
140 | | - tooltip=[alt.Tooltip("label:N", title="Department"), alt.Tooltip("formatted_value:N", title="Budget")], |
| 157 | + color=alt.Color( |
| 158 | + "group:N", |
| 159 | + scale=alt.Scale(domain=group_order, range=[group_colors[g] for g in group_order]), |
| 160 | + legend=alt.Legend( |
| 161 | + title="Division", |
| 162 | + titleFontSize=18, |
| 163 | + titleFontWeight="bold", |
| 164 | + labelFontSize=15, |
| 165 | + symbolSize=300, |
| 166 | + orient="none", |
| 167 | + legendX=1400, |
| 168 | + legendY=680, |
| 169 | + direction="vertical", |
| 170 | + ), |
| 171 | + ), |
| 172 | + tooltip=[ |
| 173 | + alt.Tooltip("label:N", title="Department"), |
| 174 | + alt.Tooltip("budget:N", title="Budget"), |
| 175 | + alt.Tooltip("group:N", title="Division"), |
| 176 | + ], |
141 | 177 | ) |
142 | 178 | ) |
143 | 179 |
|
144 | | -# Create labels for larger bubbles |
145 | | -df_large = df[df["radius"] > 55].copy() |
146 | | -df_large["display_text"] = df_large["label"] + "\n" + df_large["formatted_value"] |
| 180 | +# Labels inside larger bubbles |
| 181 | +df_large = df[df["radius"] > 50].copy() |
| 182 | +df_large["display_text"] = df_large["label"] + "\n" + df_large["budget"] |
147 | 183 |
|
148 | 184 | labels_layer = ( |
149 | 185 | alt.Chart(df_large) |
150 | | - .mark_text(color="white", fontWeight="bold", fontSize=14, lineBreak="\n") |
151 | | - .encode(x=alt.X("x:Q"), y=alt.Y("y:Q"), text="display_text:N") |
| 186 | + .mark_text(fontWeight="bold", fontSize=13, lineBreak="\n") |
| 187 | + .encode( |
| 188 | + x=alt.X("x:Q"), |
| 189 | + y=alt.Y("y:Q"), |
| 190 | + text="display_text:N", |
| 191 | + color=alt.condition(alt.datum.group == "Product", alt.value("#333333"), alt.value("white")), |
| 192 | + ) |
152 | 193 | ) |
153 | 194 |
|
154 | 195 | # Combine layers |
|
167 | 208 | .configure_view(strokeWidth=0) |
168 | 209 | ) |
169 | 210 |
|
170 | | -# Save outputs |
| 211 | +# Save |
171 | 212 | chart.save("plot.png", scale_factor=3.0) |
172 | 213 | chart.save("plot.html") |
0 commit comments