|
| 1 | +""" pyplots.ai |
| 2 | +circlepacking-basic: Circle Packing Chart |
| 3 | +Library: altair 6.0.0 | Python 3.13.11 |
| 4 | +Quality: 55/100 | Created: 2025-12-30 |
| 5 | +""" |
| 6 | + |
| 7 | +import altair as alt |
| 8 | +import numpy as np |
| 9 | +import pandas as pd |
| 10 | + |
| 11 | + |
| 12 | +# Data - Company budget allocation by department and team (values in $K) |
| 13 | +np.random.seed(42) |
| 14 | + |
| 15 | +# Leaf nodes (teams) with their budgets |
| 16 | +teams = [ |
| 17 | + # Engineering Department |
| 18 | + {"id": "eng-backend", "parent": "Engineering", "label": "Backend", "value": 180}, |
| 19 | + {"id": "eng-frontend", "parent": "Engineering", "label": "Frontend", "value": 150}, |
| 20 | + {"id": "eng-devops", "parent": "Engineering", "label": "DevOps", "value": 90}, |
| 21 | + {"id": "eng-mobile", "parent": "Engineering", "label": "Mobile", "value": 120}, |
| 22 | + # Marketing Department |
| 23 | + {"id": "mkt-digital", "parent": "Marketing", "label": "Digital", "value": 100}, |
| 24 | + {"id": "mkt-content", "parent": "Marketing", "label": "Content", "value": 80}, |
| 25 | + {"id": "mkt-brand", "parent": "Marketing", "label": "Brand", "value": 60}, |
| 26 | + # Operations Department |
| 27 | + {"id": "ops-support", "parent": "Operations", "label": "Support", "value": 70}, |
| 28 | + {"id": "ops-hr", "parent": "Operations", "label": "HR", "value": 50}, |
| 29 | + {"id": "ops-admin", "parent": "Operations", "label": "Admin", "value": 40}, |
| 30 | + # Sales Department |
| 31 | + {"id": "sales-enterprise", "parent": "Sales", "label": "Enterprise", "value": 130}, |
| 32 | + {"id": "sales-smb", "parent": "Sales", "label": "SMB", "value": 85}, |
| 33 | + {"id": "sales-partners", "parent": "Sales", "label": "Partners", "value": 55}, |
| 34 | +] |
| 35 | + |
| 36 | +# Calculate department totals |
| 37 | +dept_totals = {} |
| 38 | +for t in teams: |
| 39 | + dept_totals[t["parent"]] = dept_totals.get(t["parent"], 0) + t["value"] |
| 40 | + |
| 41 | +# Color palette - distinct colors for each department (colorblind-safe) |
| 42 | +dept_colors = { |
| 43 | + "Engineering": "#4477AA", # Blue |
| 44 | + "Sales": "#228833", # Green |
| 45 | + "Marketing": "#AA3377", # Magenta/Pink |
| 46 | + "Operations": "#EE6677", # Coral/Red |
| 47 | +} |
| 48 | + |
| 49 | +# Scale value to radius (sqrt for area-proportional sizing) |
| 50 | +max_value = max(t["value"] for t in teams) |
| 51 | +min_radius = 25 |
| 52 | +max_radius = 55 |
| 53 | + |
| 54 | + |
| 55 | +def get_team_radius(value): |
| 56 | + """Calculate radius from value using sqrt for area-proportional sizing.""" |
| 57 | + return min_radius + (max_radius - min_radius) * np.sqrt(value / max_value) |
| 58 | + |
| 59 | + |
| 60 | +def pack_circles_in_parent(circles, parent_center, parent_radius): |
| 61 | + """ |
| 62 | + Pack child circles inside a parent circle using force-directed placement. |
| 63 | + Returns list of (x, y) positions for each circle. |
| 64 | + """ |
| 65 | + n = len(circles) |
| 66 | + if n == 0: |
| 67 | + return [] |
| 68 | + |
| 69 | + radii = [c["radius"] for c in circles] |
| 70 | + |
| 71 | + # Start with circular arrangement |
| 72 | + positions = [] |
| 73 | + if n == 1: |
| 74 | + positions = [(parent_center[0], parent_center[1])] |
| 75 | + else: |
| 76 | + arrangement_r = parent_radius * 0.4 |
| 77 | + for i in range(n): |
| 78 | + angle = 2 * np.pi * i / n - np.pi / 2 |
| 79 | + x = parent_center[0] + arrangement_r * np.cos(angle) |
| 80 | + y = parent_center[1] + arrangement_r * np.sin(angle) |
| 81 | + positions.append((x, y)) |
| 82 | + |
| 83 | + # Force-directed relaxation to remove overlaps |
| 84 | + for _ in range(100): |
| 85 | + forces = [(0.0, 0.0) for _ in range(n)] |
| 86 | + |
| 87 | + # Repulsion between circles |
| 88 | + for i in range(n): |
| 89 | + for j in range(i + 1, n): |
| 90 | + dx = positions[i][0] - positions[j][0] |
| 91 | + dy = positions[i][1] - positions[j][1] |
| 92 | + dist = np.sqrt(dx * dx + dy * dy) |
| 93 | + min_dist = radii[i] + radii[j] + 3 # 3px gap |
| 94 | + |
| 95 | + if dist < min_dist and dist > 0: |
| 96 | + overlap = min_dist - dist |
| 97 | + fx = (dx / dist) * overlap * 0.5 |
| 98 | + fy = (dy / dist) * overlap * 0.5 |
| 99 | + forces[i] = (forces[i][0] + fx, forces[i][1] + fy) |
| 100 | + forces[j] = (forces[j][0] - fx, forces[j][1] - fy) |
| 101 | + |
| 102 | + # Keep circles inside parent |
| 103 | + for i in range(n): |
| 104 | + dx = positions[i][0] - parent_center[0] |
| 105 | + dy = positions[i][1] - parent_center[1] |
| 106 | + dist_from_center = np.sqrt(dx * dx + dy * dy) |
| 107 | + max_dist = parent_radius - radii[i] - 5 |
| 108 | + |
| 109 | + if dist_from_center > max_dist and dist_from_center > 0: |
| 110 | + scale = max_dist / dist_from_center |
| 111 | + positions[i] = (parent_center[0] + dx * scale, parent_center[1] + dy * scale) |
| 112 | + |
| 113 | + # Apply forces |
| 114 | + positions = [(positions[i][0] + forces[i][0], positions[i][1] + forces[i][1]) for i in range(n)] |
| 115 | + |
| 116 | + return positions |
| 117 | + |
| 118 | + |
| 119 | +# Build circle packing structure |
| 120 | +circles_data = [] |
| 121 | + |
| 122 | +# Calculate department radii based on team radii |
| 123 | +dept_radii = {} |
| 124 | +for dept in dept_totals.keys(): |
| 125 | + dept_teams = [t for t in teams if t["parent"] == dept] |
| 126 | + team_radii = [get_team_radius(t["value"]) for t in dept_teams] |
| 127 | + # Department radius should contain all teams with padding |
| 128 | + total_team_area = sum(r * r * np.pi for r in team_radii) |
| 129 | + dept_radii[dept] = np.sqrt(total_team_area / np.pi) * 1.8 + 20 |
| 130 | + |
| 131 | +# Sort departments by radius (largest first for better packing) |
| 132 | +sorted_depts = sorted(dept_radii.keys(), key=lambda d: dept_radii[d], reverse=True) |
| 133 | + |
| 134 | +# Calculate root circle radius |
| 135 | +total_dept_area = sum(r * r * np.pi for r in dept_radii.values()) |
| 136 | +root_radius = np.sqrt(total_dept_area / np.pi) * 1.6 + 30 |
| 137 | + |
| 138 | +# Position departments inside root circle |
| 139 | +dept_circles = [{"name": dept, "radius": dept_radii[dept]} for dept in sorted_depts] |
| 140 | +dept_positions = pack_circles_in_parent(dept_circles, (0, 0), root_radius) |
| 141 | + |
| 142 | +# Add root circle (Company) |
| 143 | +company_total = sum(t["value"] for t in teams) |
| 144 | +circles_data.append( |
| 145 | + { |
| 146 | + "x": 0, |
| 147 | + "y": 0, |
| 148 | + "radius": root_radius, |
| 149 | + "label": "Company", |
| 150 | + "value": company_total, |
| 151 | + "depth": 0, |
| 152 | + "color": "#2d4a6f", |
| 153 | + "department": "Company", |
| 154 | + } |
| 155 | +) |
| 156 | + |
| 157 | +# Add departments and their teams |
| 158 | +for i, dept in enumerate(sorted_depts): |
| 159 | + dept_x, dept_y = dept_positions[i] |
| 160 | + dept_r = dept_radii[dept] |
| 161 | + dept_value = dept_totals[dept] |
| 162 | + |
| 163 | + # Add department circle |
| 164 | + circles_data.append( |
| 165 | + { |
| 166 | + "x": dept_x, |
| 167 | + "y": dept_y, |
| 168 | + "radius": dept_r, |
| 169 | + "label": dept, |
| 170 | + "value": dept_value, |
| 171 | + "depth": 1, |
| 172 | + "color": dept_colors[dept], |
| 173 | + "department": dept, |
| 174 | + } |
| 175 | + ) |
| 176 | + |
| 177 | + # Position teams inside department |
| 178 | + dept_teams = [t for t in teams if t["parent"] == dept] |
| 179 | + team_circles = [{"name": t["label"], "radius": get_team_radius(t["value"])} for t in dept_teams] |
| 180 | + team_positions = pack_circles_in_parent(team_circles, (dept_x, dept_y), dept_r) |
| 181 | + |
| 182 | + for j, t in enumerate(dept_teams): |
| 183 | + tx, ty = team_positions[j] |
| 184 | + team_r = get_team_radius(t["value"]) |
| 185 | + circles_data.append( |
| 186 | + { |
| 187 | + "x": tx, |
| 188 | + "y": ty, |
| 189 | + "radius": team_r, |
| 190 | + "label": t["label"], |
| 191 | + "value": t["value"], |
| 192 | + "depth": 2, |
| 193 | + "color": dept_colors[dept], |
| 194 | + "department": dept, |
| 195 | + } |
| 196 | + ) |
| 197 | + |
| 198 | +# Create DataFrame |
| 199 | +df = pd.DataFrame(circles_data) |
| 200 | + |
| 201 | +# Create display text |
| 202 | +df["display_value"] = df["value"].apply(lambda v: f"${v}K") |
| 203 | +df["display_text"] = df.apply( |
| 204 | + lambda r: f"{r['label']}\n{r['display_value']}" if r["depth"] == 2 else r["label"], axis=1 |
| 205 | +) |
| 206 | + |
| 207 | +# Separate by depth for layered rendering |
| 208 | +df_root = df[df["depth"] == 0].copy() |
| 209 | +df_depts = df[df["depth"] == 1].copy() |
| 210 | +df_teams = df[df["depth"] == 2].copy() |
| 211 | + |
| 212 | +# Calculate dynamic scales based on actual data |
| 213 | +x_min, x_max = df["x"].min() - df["radius"].max(), df["x"].max() + df["radius"].max() |
| 214 | +y_min, y_max = df["y"].min() - df["radius"].max(), df["y"].max() + df["radius"].max() |
| 215 | + |
| 216 | +# Add padding for legend on the right |
| 217 | +padding = 50 |
| 218 | +x_domain = [x_min - padding, x_max + padding + 180] # Extra space for legend |
| 219 | +y_domain = [y_min - padding, y_max + padding] |
| 220 | + |
| 221 | +# Size scale (radius squared for area encoding) |
| 222 | +size_domain = [df["radius"].min(), df["radius"].max()] |
| 223 | +size_range = [df["radius"].min() ** 2 * 2.5, df["radius"].max() ** 2 * 2.5] |
| 224 | + |
| 225 | +# Shared scales |
| 226 | +x_scale = alt.Scale(domain=list(x_domain)) |
| 227 | +y_scale = alt.Scale(domain=list(y_domain)) |
| 228 | +size_scale = alt.Scale(domain=size_domain, range=size_range) |
| 229 | + |
| 230 | +# Root circle layer (outermost - Company) |
| 231 | +root_layer = ( |
| 232 | + alt.Chart(df_root) |
| 233 | + .mark_circle(opacity=0.2, stroke="#2d4a6f", strokeWidth=3) |
| 234 | + .encode( |
| 235 | + x=alt.X("x:Q", axis=None, scale=x_scale), |
| 236 | + y=alt.Y("y:Q", axis=None, scale=y_scale), |
| 237 | + size=alt.Size("radius:Q", scale=size_scale, legend=None), |
| 238 | + color=alt.value("#2d4a6f"), |
| 239 | + tooltip=[alt.Tooltip("label:N", title="Name"), alt.Tooltip("display_value:N", title="Budget")], |
| 240 | + ) |
| 241 | +) |
| 242 | + |
| 243 | +# Root label |
| 244 | +root_label = ( |
| 245 | + alt.Chart(df_root) |
| 246 | + .mark_text(color="#2d4a6f", fontWeight="bold", fontSize=20, dy=-root_radius + 30) |
| 247 | + .encode( |
| 248 | + x=alt.X("x:Q", axis=None, scale=x_scale), |
| 249 | + y=alt.Y("y:Q", axis=None, scale=y_scale), |
| 250 | + text=alt.value("Company Budget"), |
| 251 | + ) |
| 252 | +) |
| 253 | + |
| 254 | +# Department circles layer |
| 255 | +dept_layer = ( |
| 256 | + alt.Chart(df_depts) |
| 257 | + .mark_circle(opacity=0.4, stroke="white", strokeWidth=2) |
| 258 | + .encode( |
| 259 | + x=alt.X("x:Q", axis=None, scale=x_scale), |
| 260 | + y=alt.Y("y:Q", axis=None, scale=y_scale), |
| 261 | + size=alt.Size("radius:Q", scale=size_scale, legend=None), |
| 262 | + color=alt.Color("color:N", scale=None), |
| 263 | + tooltip=[alt.Tooltip("label:N", title="Department"), alt.Tooltip("display_value:N", title="Budget")], |
| 264 | + ) |
| 265 | +) |
| 266 | + |
| 267 | +# Team circles layer |
| 268 | +team_layer = ( |
| 269 | + alt.Chart(df_teams) |
| 270 | + .mark_circle(opacity=0.9, stroke="white", strokeWidth=1.5) |
| 271 | + .encode( |
| 272 | + x=alt.X("x:Q", axis=None, scale=x_scale), |
| 273 | + y=alt.Y("y:Q", axis=None, scale=y_scale), |
| 274 | + size=alt.Size("radius:Q", scale=size_scale, legend=None), |
| 275 | + color=alt.Color("color:N", scale=None), |
| 276 | + tooltip=[ |
| 277 | + alt.Tooltip("label:N", title="Team"), |
| 278 | + alt.Tooltip("department:N", title="Department"), |
| 279 | + alt.Tooltip("display_value:N", title="Budget"), |
| 280 | + ], |
| 281 | + ) |
| 282 | +) |
| 283 | + |
| 284 | +# Department labels - positioned at center-top of each department circle |
| 285 | +df_depts_labels = df_depts.copy() |
| 286 | +df_depts_labels["label_y"] = df_depts_labels["y"] + df_depts_labels["radius"] * 0.6 |
| 287 | + |
| 288 | +dept_label_layer = ( |
| 289 | + alt.Chart(df_depts_labels) |
| 290 | + .mark_text(color="white", fontWeight="bold", fontSize=16) |
| 291 | + .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("label_y:Q", axis=None, scale=y_scale), text="label:N") |
| 292 | +) |
| 293 | + |
| 294 | +# Team labels |
| 295 | +team_label_layer = ( |
| 296 | + alt.Chart(df_teams) |
| 297 | + .mark_text(color="white", fontWeight="bold", fontSize=11, lineBreak="\n") |
| 298 | + .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("y:Q", axis=None, scale=y_scale), text="display_text:N") |
| 299 | +) |
| 300 | + |
| 301 | +# Legend positioned inside the visible area (right side) |
| 302 | +legend_x = x_max + 60 |
| 303 | +legend_y_start = 80 |
| 304 | +legend_spacing = 45 |
| 305 | + |
| 306 | +legend_df = pd.DataFrame( |
| 307 | + [ |
| 308 | + {"department": dept, "color": dept_colors[dept], "x": legend_x, "y": legend_y_start - i * legend_spacing} |
| 309 | + for i, dept in enumerate(["Engineering", "Sales", "Marketing", "Operations"]) |
| 310 | + ] |
| 311 | +) |
| 312 | + |
| 313 | +# Legend circles |
| 314 | +legend_circles = ( |
| 315 | + alt.Chart(legend_df) |
| 316 | + .mark_circle(size=350, opacity=0.9, stroke="white", strokeWidth=1) |
| 317 | + .encode( |
| 318 | + x=alt.X("x:Q", axis=None, scale=x_scale), |
| 319 | + y=alt.Y("y:Q", axis=None, scale=y_scale), |
| 320 | + color=alt.Color("color:N", scale=None), |
| 321 | + ) |
| 322 | +) |
| 323 | + |
| 324 | +# Legend text |
| 325 | +legend_text = ( |
| 326 | + alt.Chart(legend_df) |
| 327 | + .mark_text(align="left", dx=18, fontSize=14, fontWeight="bold", color="#333333") |
| 328 | + .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("y:Q", axis=None, scale=y_scale), text="department:N") |
| 329 | +) |
| 330 | + |
| 331 | +# Combine all layers |
| 332 | +chart = ( |
| 333 | + alt.layer( |
| 334 | + root_layer, root_label, dept_layer, team_layer, dept_label_layer, team_label_layer, legend_circles, legend_text |
| 335 | + ) |
| 336 | + .properties( |
| 337 | + width=1200, |
| 338 | + height=1200, |
| 339 | + title=alt.Title("circlepacking-basic · altair · pyplots.ai", fontSize=28, fontWeight="bold", anchor="middle"), |
| 340 | + ) |
| 341 | + .configure_view(strokeWidth=0) |
| 342 | +) |
| 343 | + |
| 344 | +# Save outputs (3600x3600 px with scale_factor=3.0) |
| 345 | +chart.save("plot.png", scale_factor=3.0) |
| 346 | +chart.save("plot.html") |
0 commit comments