Skip to content

Commit ebb9b61

Browse files
feat(altair): implement circlepacking-basic (#2544)
## Implementation: `circlepacking-basic` - altair Implements the **altair** version of `circlepacking-basic`. **File:** `plots/circlepacking-basic/implementations/altair.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20585400895)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent b38ae26 commit ebb9b61

2 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
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")
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
library: altair
2+
specification_id: circlepacking-basic
3+
created: '2025-12-30T00:07:55Z'
4+
updated: '2025-12-30T00:48:58Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20585400895
7+
issue: 0
8+
python_version: 3.13.11
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/circlepacking-basic/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/circlepacking-basic/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/circlepacking-basic/altair/plot.html
13+
quality_score: 55
14+
review:
15+
strengths:
16+
- Excellent realistic business scenario with company budget allocation across departments
17+
and teams
18+
- Proper area-proportional sizing calculation using sqrt transformation
19+
- Good layered approach using Altair chart composition for depth-based rendering
20+
- Correct title format matching spec requirements
21+
- Reproducible with fixed random seed
22+
- Interactive tooltips implemented for additional data exploration
23+
weaknesses:
24+
- Circle overlap is severe - circles from different departments overlap significantly,
25+
violating the spec requirement for efficient packing without overlap
26+
- Department color differentiation not working in output - all circles appear the
27+
same blue color despite distinct colors defined in code
28+
- Legend is not visible in the rendered output despite being coded
29+
- Department parent labels not visible on the visualization
30+
- Code uses helper functions (get_team_radius, pack_circles_in_parent) which violates
31+
the KISS principle of no functions/classes

0 commit comments

Comments
 (0)