|
1 | 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: 88/100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import altair as alt |
| 8 | +import circlify |
8 | 9 | import numpy as np |
9 | 10 | import pandas as pd |
10 | 11 |
|
11 | 12 |
|
12 | | -# Data - Department budget allocation |
13 | 13 | np.random.seed(42) |
14 | | -data = { |
15 | | - "label": [ |
16 | | - "Engineering", |
17 | | - "Marketing", |
18 | | - "Sales", |
19 | | - "Operations", |
20 | | - "HR", |
21 | | - "Finance", |
22 | | - "R&D", |
23 | | - "Support", |
24 | | - "Legal", |
25 | | - "IT", |
26 | | - "Design", |
27 | | - "Product", |
28 | | - "Data Science", |
29 | | - "Security", |
30 | | - "QA", |
31 | | - ], |
32 | | - "value": [850, 420, 680, 320, 180, 290, 750, 210, 150, 380, 240, 550, 460, 170, 195], |
33 | | -} |
34 | | - |
35 | | -labels = data["label"] |
36 | | -values = data["value"] |
| 14 | + |
| 15 | +# Data - Department budget allocation by division |
| 16 | +labels = [ |
| 17 | + "Engineering", |
| 18 | + "R&D", |
| 19 | + "Data Science", |
| 20 | + "QA", |
| 21 | + "Marketing", |
| 22 | + "Sales", |
| 23 | + "Support", |
| 24 | + "Finance", |
| 25 | + "HR", |
| 26 | + "Legal", |
| 27 | + "Operations", |
| 28 | + "IT", |
| 29 | + "Security", |
| 30 | + "Design", |
| 31 | + "Product", |
| 32 | +] |
| 33 | +values = [850, 750, 460, 195, 420, 680, 210, 290, 180, 150, 320, 380, 170, 240, 550] |
| 34 | +groups = ["Technology"] * 4 + ["Revenue"] * 3 + ["Corporate"] * 3 + ["Operations"] * 3 + ["Product"] * 2 |
37 | 35 | n = len(labels) |
38 | 36 |
|
39 | | -# Scale values to radius (using sqrt for area-proportional sizing) |
40 | | -min_radius = 30 |
41 | | -max_radius = 120 |
42 | | -values_array = np.array(values) |
43 | | -radii = min_radius + (max_radius - min_radius) * np.sqrt( |
44 | | - (values_array - values_array.min()) / (values_array.max() - values_array.min()) |
45 | | -) |
| 37 | +# Circle packing layout (circlify returns ascending by value) |
| 38 | +circles = circlify.circlify(values, show_enclosure=False) |
| 39 | +idx_asc = np.argsort(values) |
| 40 | +scale = 300 |
| 41 | + |
| 42 | +x = np.zeros(n) |
| 43 | +y = np.zeros(n) |
| 44 | +radii = np.zeros(n) |
| 45 | +for ci, oi in zip(circles, idx_asc, strict=True): |
| 46 | + x[oi] = ci.x * scale |
| 47 | + y[oi] = ci.y * scale |
| 48 | + radii[oi] = ci.r * scale |
| 49 | + |
| 50 | +# Colorblind-safe palette (teal replaces sage green for deuteranopia safety) |
| 51 | +group_order = ["Technology", "Revenue", "Operations", "Corporate", "Product"] |
| 52 | +palette = ["#306998", "#E07A5F", "#8B6DA8", "#2A9D8F", "#FFD43B"] |
46 | 53 |
|
47 | | -# Sort by size (largest first) for better packing |
48 | | -order = np.argsort(-radii) |
49 | | -radii = radii[order] |
50 | | -labels = [labels[i] for i in order] |
51 | | -values = [values[i] for i in order] |
52 | | - |
53 | | -# Circle packing - place circles one by one, finding best position |
54 | | -x_pos = np.zeros(n) |
55 | | -y_pos = np.zeros(n) |
56 | | - |
57 | | -# Place first circle at center |
58 | | -x_pos[0] = 0 |
59 | | -y_pos[0] = 0 |
60 | | - |
61 | | -# Place remaining circles |
62 | | -for i in range(1, n): |
63 | | - best_x, best_y = 0, 0 |
64 | | - best_dist = float("inf") |
65 | | - |
66 | | - # Try positions around existing circles |
67 | | - for j in range(i): |
68 | | - for angle in np.linspace(0, 2 * np.pi, 36, endpoint=False): |
69 | | - # Position touching circle j |
70 | | - test_x = x_pos[j] + (radii[j] + radii[i] + 2) * np.cos(angle) |
71 | | - test_y = y_pos[j] + (radii[j] + radii[i] + 2) * np.sin(angle) |
72 | | - |
73 | | - # Check for overlaps with all placed circles |
74 | | - valid = True |
75 | | - for k in range(i): |
76 | | - dx = test_x - x_pos[k] |
77 | | - dy = test_y - y_pos[k] |
78 | | - dist = np.sqrt(dx**2 + dy**2) |
79 | | - if dist < radii[i] + radii[k] + 1: |
80 | | - valid = False |
81 | | - break |
82 | | - |
83 | | - if valid: |
84 | | - # Prefer positions closer to center |
85 | | - center_dist = np.sqrt(test_x**2 + test_y**2) |
86 | | - if center_dist < best_dist: |
87 | | - best_dist = center_dist |
88 | | - best_x, best_y = test_x, test_y |
89 | | - |
90 | | - x_pos[i] = best_x |
91 | | - y_pos[i] = best_y |
92 | | - |
93 | | -# Fine-tune with physics simulation |
94 | | -for _ in range(200): |
95 | | - 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 |
101 | | - for j in range(n): |
102 | | - if i != j: |
103 | | - dx = x_pos[i] - x_pos[j] |
104 | | - dy = y_pos[i] - y_pos[j] |
105 | | - dist = np.sqrt(dx**2 + dy**2) + 0.1 |
106 | | - min_dist = radii[i] + radii[j] + 2 |
107 | | - if dist < min_dist: |
108 | | - force = (min_dist - dist) * 0.5 |
109 | | - fx += (dx / dist) * force |
110 | | - fy += (dy / dist) * force |
111 | | - x_pos[i] += fx |
112 | | - y_pos[i] += fy |
113 | | - |
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)] |
117 | | - |
118 | | -# Create DataFrame with computed positions |
119 | 54 | df = pd.DataFrame( |
120 | 55 | { |
121 | 56 | "label": labels, |
122 | 57 | "value": values, |
123 | | - "x": x_pos, |
124 | | - "y": y_pos, |
| 58 | + "group": groups, |
| 59 | + "x": x, |
| 60 | + "y": y, |
125 | 61 | "radius": radii, |
126 | | - "color": colors, |
127 | | - "formatted_value": [f"${v}K" for v in values], |
| 62 | + "budget": [f"${v}K" for v in values], |
128 | 63 | } |
129 | 64 | ) |
130 | 65 |
|
131 | | -# Create circles using mark_circle with computed positions |
132 | | -circles = ( |
| 66 | +# Interactive legend selection — click to highlight a division (Altair-distinctive) |
| 67 | +selection = alt.selection_point(fields=["group"], bind="legend") |
| 68 | + |
| 69 | +r_min, r_max = radii.min(), radii.max() |
| 70 | +circles_layer = ( |
133 | 71 | alt.Chart(df) |
134 | | - .mark_circle(opacity=0.85, stroke="white", strokeWidth=2) |
| 72 | + .mark_circle(stroke="white", strokeWidth=2.5) |
135 | 73 | .encode( |
136 | | - x=alt.X("x:Q", axis=None), |
137 | | - y=alt.Y("y:Q", axis=None), |
138 | | - 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")], |
| 74 | + x=alt.X("x:Q", axis=None, scale=alt.Scale(padding=r_max * 0.6)), |
| 75 | + y=alt.Y("y:Q", axis=None, scale=alt.Scale(padding=r_max * 0.6)), |
| 76 | + size=alt.Size("radius:Q", scale=alt.Scale(range=[r_min**2 * 10, r_max**2 * 10]), legend=None), |
| 77 | + color=alt.Color( |
| 78 | + "group:N", |
| 79 | + scale=alt.Scale(domain=group_order, range=palette), |
| 80 | + legend=alt.Legend( |
| 81 | + title="Division", |
| 82 | + titleFontSize=20, |
| 83 | + titleFontWeight="bold", |
| 84 | + labelFontSize=18, |
| 85 | + symbolSize=350, |
| 86 | + orient="right", |
| 87 | + ), |
| 88 | + ), |
| 89 | + opacity=alt.condition(selection, alt.value(0.9), alt.value(0.15)), |
| 90 | + tooltip=[ |
| 91 | + alt.Tooltip("label:N", title="Department"), |
| 92 | + alt.Tooltip("budget:N", title="Budget"), |
| 93 | + alt.Tooltip("group:N", title="Division"), |
| 94 | + ], |
141 | 95 | ) |
| 96 | + .add_params(selection) |
142 | 97 | ) |
143 | 98 |
|
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"] |
| 99 | +# Labels inside larger bubbles (two-line: department + budget) |
| 100 | +df_large = df[df["radius"] >= r_min + (r_max - r_min) * 0.25].copy() |
| 101 | +df_large["display_text"] = df_large["label"] + "\n" + df_large["budget"] |
147 | 102 |
|
148 | | -labels_layer = ( |
| 103 | +large_labels = ( |
149 | 104 | 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") |
| 105 | + .mark_text(fontWeight="bold", fontSize=20, lineBreak="\n") |
| 106 | + .encode( |
| 107 | + x="x:Q", |
| 108 | + y="y:Q", |
| 109 | + text="display_text:N", |
| 110 | + color=alt.condition(alt.datum.group == "Product", alt.value("#333333"), alt.value("white")), |
| 111 | + opacity=alt.condition(selection, alt.value(1.0), alt.value(0.1)), |
| 112 | + ) |
| 113 | +) |
| 114 | + |
| 115 | +# Labels for smaller bubbles (department name for identification in static PNG) |
| 116 | +df_small = df[df["radius"] < r_min + (r_max - r_min) * 0.25].copy() |
| 117 | + |
| 118 | +small_labels = ( |
| 119 | + alt.Chart(df_small) |
| 120 | + .mark_text(fontWeight="bold", fontSize=15) |
| 121 | + .encode( |
| 122 | + x="x:Q", |
| 123 | + y="y:Q", |
| 124 | + text="label:N", |
| 125 | + color=alt.condition(alt.datum.group == "Product", alt.value("#333333"), alt.value("white")), |
| 126 | + opacity=alt.condition(selection, alt.value(1.0), alt.value(0.1)), |
| 127 | + ) |
152 | 128 | ) |
153 | 129 |
|
154 | | -# Combine layers |
155 | 130 | chart = ( |
156 | | - alt.layer(circles, labels_layer) |
| 131 | + alt.layer(circles_layer, large_labels, small_labels) |
157 | 132 | .properties( |
158 | | - width=1600, |
159 | | - height=900, |
| 133 | + width=1200, |
| 134 | + height=1200, |
160 | 135 | title=alt.Title( |
161 | 136 | "Department Budget Allocation · bubble-packed · altair · pyplots.ai", |
| 137 | + subtitle="Technology division leads at 39% of total budget — Engineering alone accounts for $850K", |
162 | 138 | fontSize=28, |
| 139 | + subtitleFontSize=18, |
| 140 | + subtitleColor="#555555", |
163 | 141 | fontWeight="bold", |
164 | 142 | anchor="middle", |
165 | 143 | ), |
166 | 144 | ) |
167 | 145 | .configure_view(strokeWidth=0) |
168 | 146 | ) |
169 | 147 |
|
170 | | -# Save outputs |
171 | 148 | chart.save("plot.png", scale_factor=3.0) |
172 | 149 | chart.save("plot.html") |
0 commit comments