Skip to content

Commit 1c29175

Browse files
feat(seaborn): implement sunburst-basic (#9923)
## Implementation: `sunburst-basic` - python/seaborn Implements the **python/seaborn** version of `sunburst-basic`. **File:** `plots/sunburst-basic/implementations/python/seaborn.py` **Parent Issue:** #821 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/30187132041)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 72a5b78 commit 1c29175

2 files changed

Lines changed: 279 additions & 174 deletions

File tree

Lines changed: 148 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
""" anyplot.ai
22
sunburst-basic: Basic Sunburst Chart
3-
Library: seaborn 0.13.2 | Python 3.13.13
4-
Quality: 83/100 | Updated: 2026-05-04
3+
Library: seaborn 0.13.2 | Python 3.13.14
4+
Quality: 84/100 | Updated: 2026-07-26
55
"""
66

77
import os
88

9+
import matplotlib.colors as mcolors
10+
import matplotlib.patheffects as path_effects
911
import matplotlib.pyplot as plt
1012
import numpy as np
1113
import pandas as pd
1214
import seaborn as sns
15+
import seaborn.objects as so
1316

1417

1518
# Theme tokens
@@ -19,25 +22,47 @@
1922
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
2023
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
2124

22-
# Okabe-Ito palette — first series always #009E73
23-
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]
25+
# Imprint palette — first series always #009E73; abstract folders get canonical order
26+
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]
2427

25-
# Hierarchical data: Company budget breakdown (in $K)
26-
# Level 1: Departments, Level 2: Teams, Level 3: Projects
28+
# Ring-label text colors, chosen per-wedge from the wedge's own fill luminance rather than
29+
# a fixed theme token — wedge fills are data colors (branch tints), not page chrome, so their
30+
# brightness varies independently of THEME and a single INK/INK_SOFT choice can't stay legible
31+
# across the whole range from pale tints to fully-saturated branch colors.
32+
WEDGE_LABEL_LIGHT = "#F0EFE8" # for text on saturated/dark wedge fills
33+
WEDGE_LABEL_DARK = "#1A1A17" # for text on pale wedge tints
34+
35+
36+
def _label_color(hex_color):
37+
r, g, b = mcolors.to_rgb(hex_color)
38+
luminance = 0.299 * r + 0.587 * g + 0.114 * b
39+
return WEDGE_LABEL_DARK if luminance > 0.5 else WEDGE_LABEL_LIGHT
40+
41+
42+
def _label_halo(fill_color):
43+
# Opposite-tone halo so a label stays legible even where it overhangs its wedge
44+
# onto the page background — PAGE_BG flips between light/dark per THEME, but the
45+
# wedge-derived fill color above doesn't, so a plain fill can vanish into PAGE_BG.
46+
halo_color = WEDGE_LABEL_LIGHT if fill_color == WEDGE_LABEL_DARK else WEDGE_LABEL_DARK
47+
return [path_effects.withStroke(linewidth=2.5, foreground=halo_color)]
48+
49+
50+
# Hierarchical data: a repository's disk usage (in MB)
51+
# Level 1: top-level directories, Level 2: subdirectories, Level 3: leaf folders
2752
data = {
28-
"Engineering": {
29-
"Frontend": {"Web App": 150, "Mobile": 120},
30-
"Backend": {"API": 180, "Database": 90},
31-
"DevOps": {"Cloud": 100, "CI/CD": 60},
53+
"src/": {
54+
"components/": {"widgets/": 42, "forms/": 28},
55+
"services/": {"api-client/": 35, "auth/": 18},
56+
"utils/": {"helpers/": 15, "validators/": 10},
3257
},
33-
"Sales": {"North": {"Enterprise": 200, "SMB": 80}, "South": {"Enterprise": 150, "SMB": 70}},
34-
"Marketing": {"Digital": {"SEO": 60, "Ads": 140}, "Brand": {"Events": 80, "Content": 50}},
58+
"assets/": {"images/": {"icons/": 22, "photos/": 48}, "fonts/": {"sans/": 8, "serif/": 6}},
59+
"tests/": {"integration/": {"api/": 24, "e2e/": 30}, "unit/": {"components/": 20, "services/": 16}},
60+
"docs/": {"guides/": {"getting-started/": 5, "tutorials/": 9}, "reference/": {"api/": 12, "changelog/": 3}},
3561
}
3662

3763
sns.set_theme(
3864
style="white",
39-
context="poster",
40-
font_scale=1.1,
65+
context="notebook",
4166
rc={
4267
"figure.facecolor": PAGE_BG,
4368
"axes.facecolor": PAGE_BG,
@@ -47,140 +72,167 @@
4772
"xtick.color": INK_SOFT,
4873
"ytick.color": INK_SOFT,
4974
"grid.color": INK,
50-
"grid.alpha": 0.10,
75+
"grid.alpha": 0.12,
5176
"legend.facecolor": ELEVATED_BG,
5277
"legend.edgecolor": INK_SOFT,
5378
},
5479
)
5580

56-
# Build hierarchical structure and color scheme
57-
level1_names = []
58-
level1_values = []
59-
level2_names = []
60-
level2_values = []
61-
level3_names = []
62-
level3_values = []
63-
level1_colors = []
64-
level2_colors = []
65-
level3_colors = []
66-
67-
for i, (dept, teams) in enumerate(data.items()):
68-
dept_total = sum(sum(projs.values()) for projs in teams.values())
69-
level1_names.append(dept)
70-
level1_values.append(dept_total)
81+
# Build hierarchical structure and branch-coherent colors
82+
level1_names, level1_values, level1_colors = [], [], []
83+
level2_names, level2_values, level2_colors = [], [], []
84+
level3_names, level3_values, level3_colors = [], [], []
85+
86+
for i, (top_dir, subdirs) in enumerate(data.items()):
87+
dir_total = sum(sum(leaves.values()) for leaves in subdirs.values())
88+
level1_names.append(top_dir)
89+
level1_values.append(dir_total)
7190
base_color = IMPRINT[i % len(IMPRINT)]
7291
level1_colors.append(base_color)
7392

74-
# Light palette for child levels shows branch relationships via seaborn
75-
dept_light_palette = sns.light_palette(base_color, n_colors=6, reverse=False)
93+
# Light tints of the branch color carry the parent/child relationship visually
94+
branch_tints = sns.light_palette(base_color, n_colors=6)
7695

77-
for j, (team, projects) in enumerate(teams.items()):
78-
team_total = sum(projects.values())
79-
level2_names.append(team)
80-
level2_values.append(team_total)
81-
level2_colors.append(dept_light_palette[3 + j % 2])
96+
for j, (subdir, leaves) in enumerate(subdirs.items()):
97+
subdir_total = sum(leaves.values())
98+
level2_names.append(subdir)
99+
level2_values.append(subdir_total)
100+
level2_colors.append(branch_tints[3 + j % 2])
82101

83-
for k, (proj, value) in enumerate(projects.items()):
84-
level3_names.append(proj)
85-
level3_values.append(value)
86-
level3_colors.append(dept_light_palette[4 + k % 2])
102+
for k, (leaf, size) in enumerate(leaves.items()):
103+
level3_names.append(leaf)
104+
level3_values.append(size)
105+
level3_colors.append(branch_tints[4 + k % 2])
87106

88-
# Figure layout: sunburst left, summary bar chart right
89-
fig = plt.figure(figsize=(16, 9), facecolor=PAGE_BG)
90-
ax_sun = fig.add_axes([0.02, 0.05, 0.58, 0.85])
91-
ax_bar = fig.add_axes([0.65, 0.15, 0.32, 0.70])
107+
108+
# Figure layout: sunburst left, branch-size summary right
109+
fig = plt.figure(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
110+
ax_sun = fig.add_axes((0.02, 0.05, 0.54, 0.86))
111+
ax_bar = fig.add_axes((0.60, 0.18, 0.36, 0.58))
92112
ax_sun.set_facecolor(PAGE_BG)
93113
ax_bar.set_facecolor(PAGE_BG)
94114

95-
ring_width = 0.28
115+
ring_width = 0.32
96116
inner_radius = 0.22
97117

98-
# Level 3 (outermost ring)
118+
# Level 3 (outermost ring — leaf folders)
99119
wedges3, _ = ax_sun.pie(
100120
level3_values,
101121
radius=inner_radius + 3 * ring_width,
102122
colors=level3_colors,
103123
startangle=90,
104124
counterclock=False,
105-
wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2.5},
125+
wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2},
106126
)
107127

108-
# Level 2 (middle ring)
128+
# Level 2 (middle ring — subdirectories)
109129
wedges2, _ = ax_sun.pie(
110130
level2_values,
111131
radius=inner_radius + 2 * ring_width,
112132
colors=level2_colors,
113133
startangle=90,
114134
counterclock=False,
115-
wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2.5},
135+
wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2},
116136
)
117137

118-
# Level 1 (innermost ring)white labels contrast against saturated Okabe-Ito fills
119-
wedges1, _ = ax_sun.pie(
138+
# Level 1 (innermost ring — top-level directories)
139+
wedges1, texts1 = ax_sun.pie(
120140
level1_values,
121141
radius=inner_radius + ring_width,
122142
colors=level1_colors,
123143
labels=level1_names,
124-
labeldistance=0.55,
144+
labeldistance=0.6,
125145
startangle=90,
126146
counterclock=False,
127-
wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2.5},
128-
textprops={"fontsize": 18, "fontweight": "bold", "color": "white"},
147+
wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2},
148+
textprops={"fontsize": 14, "fontweight": "bold"},
129149
)
150+
for text, color in zip(texts1, level1_colors, strict=True):
151+
fill = _label_color(color)
152+
text.set_color(fill)
153+
text.set_path_effects(_label_halo(fill))
130154

131-
# Level 2 labels (only for large enough segments)
155+
# Level 2 labels — horizontal, shown only where the wedge is wide enough to hold text
156+
level2_label_positions = [] # (x, y) of rendered labels, checked by level-3 labels below
132157
for i, wedge in enumerate(wedges2):
133158
ang = (wedge.theta2 + wedge.theta1) / 2
159+
span = wedge.theta2 - wedge.theta1
160+
if span <= 15:
161+
continue
134162
r = inner_radius + 1.5 * ring_width
135163
x = r * np.cos(np.radians(ang))
136164
y = r * np.sin(np.radians(ang))
137-
if (wedge.theta2 - wedge.theta1) > 18:
138-
ax_sun.text(x, y, level2_names[i], ha="center", va="center", fontsize=13, fontweight="medium", color=INK)
139-
140-
# Level 3 labels (higher threshold to avoid crowding)
165+
fill = _label_color(level2_colors[i])
166+
ax_sun.text(
167+
x,
168+
y,
169+
level2_names[i].rstrip("/"),
170+
ha="center",
171+
va="center",
172+
fontsize=12,
173+
color=fill,
174+
path_effects=_label_halo(fill),
175+
)
176+
level2_label_positions.append((x, y))
177+
178+
# Level 3 labels — higher threshold to avoid crowding the outermost, narrowest wedges
141179
for i, wedge in enumerate(wedges3):
142180
ang = (wedge.theta2 + wedge.theta1) / 2
143-
r = inner_radius + 2.5 * ring_width
181+
span = wedge.theta2 - wedge.theta1
182+
if span <= 10:
183+
continue
184+
r = inner_radius + 2.6 * ring_width
144185
x = r * np.cos(np.radians(ang))
145186
y = r * np.sin(np.radians(ang))
146-
if (wedge.theta2 - wedge.theta1) > 16:
147-
ax_sun.text(x, y, level3_names[i], ha="center", va="center", fontsize=11, color=INK_SOFT)
148-
149-
# Center text showing total
150-
total_budget = sum(level1_values)
151-
ax_sun.text(0, 0, f"${total_budget:,}K\nTotal", ha="center", va="center", fontsize=22, fontweight="bold", color=INK)
187+
# Skip if a level-2 label already sits on almost the same horizontal line — at this
188+
# radius scale their text would run together (e.g. "componentsforms") with no visible gap.
189+
if any(abs(y - ly) < 0.08 and abs(x - lx) < 0.9 for lx, ly in level2_label_positions):
190+
continue
191+
fill = _label_color(level3_colors[i])
192+
ax_sun.text(
193+
x,
194+
y,
195+
level3_names[i].rstrip("/"),
196+
ha="center",
197+
va="center",
198+
fontsize=10,
199+
color=fill,
200+
path_effects=_label_halo(fill),
201+
)
202+
203+
# Center text showing repository total
204+
total_size = sum(level1_values)
205+
ax_sun.text(0, 0, f"{total_size} MB", ha="center", va="center", fontsize=15, fontweight="bold", color=INK)
152206
ax_sun.set_aspect("equal")
153-
154-
# Bar chart with palette matching sunburst branch colors
155-
dept_palette = dict(zip(level1_names, IMPRINT, strict=True))
156-
df_dept = pd.DataFrame({"Department": level1_names, "Budget ($K)": level1_values})
157-
sns.barplot(
158-
data=df_dept,
159-
y="Department",
160-
x="Budget ($K)",
161-
hue="Department",
162-
palette=dept_palette,
163-
ax=ax_bar,
164-
legend=False,
165-
edgecolor=PAGE_BG,
166-
linewidth=2,
207+
outer_radius = inner_radius + 3 * ring_width
208+
sun_lim = outer_radius * 1.08
209+
ax_sun.set_xlim(-sun_lim, sun_lim)
210+
ax_sun.set_ylim(-sun_lim, sun_lim)
211+
212+
# Companion panel: directory totals via seaborn's Objects interface (so.Plot),
213+
# colored with the same branch hues as the sunburst rings
214+
df_dir = pd.DataFrame({"Directory": level1_names, "Size (MB)": level1_values}).sort_values("Size (MB)", ascending=True)
215+
(
216+
so.Plot(df_dir, x="Size (MB)", y="Directory", color="Directory")
217+
.add(so.Bar(edgecolor=PAGE_BG, edgewidth=2))
218+
.scale(color=so.Nominal(dict(zip(level1_names, IMPRINT, strict=True))))
219+
.on(ax_bar)
220+
.plot()
167221
)
222+
for legend in fig.legends:
223+
legend.set_visible(False)
168224

169-
ax_bar.set_xlabel("Budget ($K)", fontsize=18, color=INK)
225+
ax_bar.set_xlabel("Size (MB)", fontsize=20, color=INK)
170226
ax_bar.set_ylabel("", color=INK)
171227
ax_bar.tick_params(axis="both", labelsize=16, colors=INK_SOFT)
172-
ax_bar.set_title("Department Totals", fontsize=20, fontweight="bold", pad=15, color=INK)
173-
ax_bar.grid(True, axis="x", alpha=0.15, color=INK)
174-
ax_bar.spines["top"].set_visible(False)
175-
ax_bar.spines["right"].set_visible(False)
176-
ax_bar.spines["left"].set_color(INK_SOFT)
177-
ax_bar.spines["bottom"].set_color(INK_SOFT)
178-
179-
for i, v in enumerate(level1_values):
180-
ax_bar.text(v + 15, i, f"${v}K", va="center", fontsize=14, fontweight="medium", color=INK)
181-
182-
fig.suptitle(
183-
"Company Budget · sunburst-basic · seaborn · anyplot.ai", fontsize=26, fontweight="bold", y=0.98, color=INK
184-
)
228+
ax_bar.set_title("Directory Totals", fontsize=18, fontweight="bold", pad=12, color=INK)
229+
ax_bar.xaxis.grid(True, alpha=0.15, color=INK)
230+
sns.despine(ax=ax_bar)
231+
232+
for i, v in enumerate(df_dir["Size (MB)"]):
233+
ax_bar.text(v + 4, i, f"{v} MB", va="center", fontsize=13, fontweight="normal", color=INK)
234+
ax_bar.set_xlim(0, max(level1_values) * 1.22)
235+
236+
fig.suptitle("sunburst-basic · python · seaborn · anyplot.ai", fontsize=18, fontweight="bold", y=0.98, color=INK)
185237

186-
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)
238+
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

0 commit comments

Comments
 (0)