|
1 | 1 | """ anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
8 | 8 |
|
| 9 | +import matplotlib.colors as mcolors |
| 10 | +import matplotlib.patheffects as path_effects |
9 | 11 | import matplotlib.pyplot as plt |
10 | 12 | import numpy as np |
11 | 13 | import pandas as pd |
12 | 14 | import seaborn as sns |
| 15 | +import seaborn.objects as so |
13 | 16 |
|
14 | 17 |
|
15 | 18 | # Theme tokens |
|
19 | 22 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
20 | 23 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
21 | 24 |
|
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"] |
24 | 27 |
|
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 |
27 | 52 | 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}, |
32 | 57 | }, |
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}}, |
35 | 61 | } |
36 | 62 |
|
37 | 63 | sns.set_theme( |
38 | 64 | style="white", |
39 | | - context="poster", |
40 | | - font_scale=1.1, |
| 65 | + context="notebook", |
41 | 66 | rc={ |
42 | 67 | "figure.facecolor": PAGE_BG, |
43 | 68 | "axes.facecolor": PAGE_BG, |
|
47 | 72 | "xtick.color": INK_SOFT, |
48 | 73 | "ytick.color": INK_SOFT, |
49 | 74 | "grid.color": INK, |
50 | | - "grid.alpha": 0.10, |
| 75 | + "grid.alpha": 0.12, |
51 | 76 | "legend.facecolor": ELEVATED_BG, |
52 | 77 | "legend.edgecolor": INK_SOFT, |
53 | 78 | }, |
54 | 79 | ) |
55 | 80 |
|
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) |
71 | 90 | base_color = IMPRINT[i % len(IMPRINT)] |
72 | 91 | level1_colors.append(base_color) |
73 | 92 |
|
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) |
76 | 95 |
|
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]) |
82 | 101 |
|
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]) |
87 | 106 |
|
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)) |
92 | 112 | ax_sun.set_facecolor(PAGE_BG) |
93 | 113 | ax_bar.set_facecolor(PAGE_BG) |
94 | 114 |
|
95 | | -ring_width = 0.28 |
| 115 | +ring_width = 0.32 |
96 | 116 | inner_radius = 0.22 |
97 | 117 |
|
98 | | -# Level 3 (outermost ring) |
| 118 | +# Level 3 (outermost ring — leaf folders) |
99 | 119 | wedges3, _ = ax_sun.pie( |
100 | 120 | level3_values, |
101 | 121 | radius=inner_radius + 3 * ring_width, |
102 | 122 | colors=level3_colors, |
103 | 123 | startangle=90, |
104 | 124 | counterclock=False, |
105 | | - wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2.5}, |
| 125 | + wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2}, |
106 | 126 | ) |
107 | 127 |
|
108 | | -# Level 2 (middle ring) |
| 128 | +# Level 2 (middle ring — subdirectories) |
109 | 129 | wedges2, _ = ax_sun.pie( |
110 | 130 | level2_values, |
111 | 131 | radius=inner_radius + 2 * ring_width, |
112 | 132 | colors=level2_colors, |
113 | 133 | startangle=90, |
114 | 134 | counterclock=False, |
115 | | - wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2.5}, |
| 135 | + wedgeprops={"width": ring_width, "edgecolor": PAGE_BG, "linewidth": 2}, |
116 | 136 | ) |
117 | 137 |
|
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( |
120 | 140 | level1_values, |
121 | 141 | radius=inner_radius + ring_width, |
122 | 142 | colors=level1_colors, |
123 | 143 | labels=level1_names, |
124 | | - labeldistance=0.55, |
| 144 | + labeldistance=0.6, |
125 | 145 | startangle=90, |
126 | 146 | 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"}, |
129 | 149 | ) |
| 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)) |
130 | 154 |
|
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 |
132 | 157 | for i, wedge in enumerate(wedges2): |
133 | 158 | ang = (wedge.theta2 + wedge.theta1) / 2 |
| 159 | + span = wedge.theta2 - wedge.theta1 |
| 160 | + if span <= 15: |
| 161 | + continue |
134 | 162 | r = inner_radius + 1.5 * ring_width |
135 | 163 | x = r * np.cos(np.radians(ang)) |
136 | 164 | 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 |
141 | 179 | for i, wedge in enumerate(wedges3): |
142 | 180 | 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 |
144 | 185 | x = r * np.cos(np.radians(ang)) |
145 | 186 | 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) |
152 | 206 | 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() |
167 | 221 | ) |
| 222 | +for legend in fig.legends: |
| 223 | + legend.set_visible(False) |
168 | 224 |
|
169 | | -ax_bar.set_xlabel("Budget ($K)", fontsize=18, color=INK) |
| 225 | +ax_bar.set_xlabel("Size (MB)", fontsize=20, color=INK) |
170 | 226 | ax_bar.set_ylabel("", color=INK) |
171 | 227 | 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) |
185 | 237 |
|
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