Skip to content

Commit 4106d2c

Browse files
github-actions[bot]claudeMarkusNeusinger
authored
feat(pygal): implement sunburst-basic (#9927)
## Implementation: `sunburst-basic` - python/pygal Implements the **python/pygal** version of `sunburst-basic`. **File:** `plots/sunburst-basic/implementations/python/pygal.py` **Parent Issue:** #821 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/30187416978)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 06f6d5f commit 4106d2c

2 files changed

Lines changed: 187 additions & 123 deletions

File tree

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
""" anyplot.ai
22
sunburst-basic: Basic Sunburst Chart
3-
Library: pygal 3.1.0 | Python 3.13.13
4-
Quality: 73/100 | Created: 2026-05-04
3+
Library: pygal 3.1.3 | Python 3.13.14
4+
Quality: 75/100 | Updated: 2026-07-26
55
"""
66

77
import os
8+
import re
89

10+
import cairosvg
911
import pygal
1012
from pygal.style import Style
1113

@@ -15,7 +17,17 @@
1517
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
1618
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
1719

18-
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")
20+
# All 4 Imprint wedge hues are dark enough that pygal auto-picks BLACK
21+
# print_labels text (contrast-matched against the wedge fill it normally
22+
# sits on). But a few team labels land past the disk's outer edge on the
23+
# plain page background instead of on a wedge; there pygal's per-series
24+
# fill has no way to know it's off-wedge, so in dark theme black text goes
25+
# invisible on the near-black canvas. A thin light halo (independent of
26+
# THEME, since the black fill itself is theme-independent) keeps every
27+
# label legible everywhere it can land, without touching on-wedge contrast.
28+
LABEL_HALO = "#F0EFE8"
29+
30+
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233")
1931

2032
custom_style = Style(
2133
background=PAGE_BG,
@@ -24,58 +36,87 @@
2436
foreground_strong=INK,
2537
foreground_subtle=INK_MUTED,
2638
colors=IMPRINT,
27-
title_font_size=72,
28-
label_font_size=40,
29-
major_label_font_size=36,
30-
legend_font_size=40,
31-
value_font_size=32,
32-
stroke_width=2,
39+
title_font_size=66,
40+
label_font_size=56,
41+
major_label_font_size=40,
42+
legend_font_size=44,
43+
value_font_size=36,
44+
value_label_font_size=38,
45+
stroke_width=3,
3346
)
3447

35-
# Organizational budget breakdown (in $K)
36-
# Each department: (name, primary_color, [(team, team_color, value), ...])
37-
# Team colors are shades of their parent department color
48+
# Organizational budget breakdown (in $K). Each department is added as its
49+
# own pygal series (its teams are that series' datapoints), NOT as two
50+
# parallel "Departments" + "Teams" series. That distinction is what makes
51+
# pygal's multi-series "dual" Pie mode produce a genuine sunburst: dual mode
52+
# sizes each series' coarse inner wedge by that series' OWN value sum, and
53+
# subdivides its thin outer-ring arc by its own datapoints. With one series
54+
# per department, the inner disk becomes 4 wedges sized by true department
55+
# share, and each wedge's outer arc subdivides proportionally by that
56+
# department's teams. (A "Departments" + "Teams" series pair does not work:
57+
# both series always sum to the same grand total, so their coarse wedges
58+
# would always split 50/50 no matter what the real department shares are.)
59+
#
60+
# Every department lists exactly the same number of teams (3): pygal pads
61+
# shorter series with a trailing zero-value point to match the longest
62+
# series, which would silently steal the "last datapoint" slot the trick
63+
# below depends on.
64+
#
65+
# Within each department, the smallest team is listed last. pygal's dual
66+
# mode paints the coarse (department-level) wedge using the metadata of the
67+
# LAST datapoint drawn, so giving that entry `label=<department name>`
68+
# is what puts the department name on the big inner wedge. Its own outer
69+
# sliver stays label-free because pygal only prints a datapoint's label when
70+
# its angular span is at least ~17 degrees (0.3 radians), and each smallest
71+
# team is kept under that share by construction; its `tooltip` still
72+
# surfaces the real team name on hover.
3873
departments = [
3974
(
4075
"Technology",
41-
"#009E73",
4276
[
43-
("Backend Engineering", "#009E73", 180),
44-
("Frontend Engineering", "#40B88E", 140),
45-
("Data Science", "#7DD3AB", 100),
77+
("Backend Engineering", "#009E73", 200),
78+
("Frontend Engineering", "#40B88E", 175),
79+
("Data Science", "#7DD3AB", 45),
4680
],
4781
),
48-
("Business", "#C475FD", [("Sales", "#C475FD", 150), ("Finance", "#D195FE", 100), ("Legal", "#DEB5FE", 60)]), # imprint lavender family
49-
("Operations", "#4467A3", [("IT Support", "#4467A3", 90), ("Human Resources", "#7A93BD", 80)]), # imprint blue family
50-
("Marketing", "#BD8233", [("Brand Design", "#BD8233", 60), ("Digital Marketing", "#D1A668", 40)]), # imprint ochre family
82+
("Business", [("Sales", "#C475FD", 160), ("Finance", "#D195FE", 105), ("Legal", "#DEB5FE", 45)]),
83+
("Operations", [("IT Support", "#4467A3", 85), ("Human Resources", "#7A93BD", 45), ("Facilities", "#A8BFDA", 40)]),
84+
(
85+
"Marketing",
86+
[("Brand Design", "#BD8233", 45), ("Digital Marketing", "#D1A668", 30), ("Content Marketing", "#E3C89C", 25)],
87+
),
5188
]
5289

53-
# pygal.Pie with multiple series creates concentric rings — a sunburst chart
54-
# inner_radius=0.4 creates a donut; first series = inner ring (departments)
5590
chart = pygal.Pie(
5691
style=custom_style,
57-
width=3600,
58-
height=3600,
59-
title="sunburst-basic · pygal · anyplot.ai",
60-
inner_radius=0.4,
61-
show_legend=True,
62-
legend_at_bottom=True,
63-
legend_at_bottom_columns=4,
92+
width=2400,
93+
height=2400,
94+
title="sunburst-basic · python · pygal · anyplot.ai",
95+
show_legend=False,
96+
print_labels=True,
6497
print_values=False,
98+
margin=380,
6599
)
66100

67-
# Inner ring: department totals — each slice color matches the department
68-
chart.add(
69-
"Departments",
70-
[{"value": sum(v for _, _, v in teams), "label": name, "color": color} for name, color, teams in departments],
71-
)
101+
for dept_name, teams in departments:
102+
points = [{"value": value, "color": color, "label": name, "tooltip": name} for name, color, value in teams[:-1]]
103+
carrier_name, carrier_color, carrier_value = teams[-1]
104+
points.append({"value": carrier_value, "color": carrier_color, "label": dept_name, "tooltip": carrier_name})
105+
chart.add(dept_name, points)
72106

73-
# Outer ring: individual teams — shades of their parent department color
74-
chart.add(
75-
"Teams",
76-
[{"value": value, "label": name, "color": color} for _, _, teams in departments for name, color, value in teams],
107+
svg = chart.render(is_unicode=True)
108+
chart_id = re.search(r'id="(chart-[^"]+)"', svg).group(1)
109+
# pygal's base stylesheet ships `#chart-id text, #chart-id tspan
110+
# {stroke:none!important}`, which would otherwise strip the halo above -
111+
# match its id-scoped specificity (plus !important) to win the cascade.
112+
svg = svg.replace(
113+
'<style type="text/css">',
114+
f'<style type="text/css">#{chart_id} .text-overlay text.label '
115+
f"{{stroke:{LABEL_HALO} !important;stroke-width:2px !important;"
116+
f"stroke-linejoin:round !important;}}",
117+
1,
77118
)
78119

79-
chart.render_to_png(f"plot-{THEME}.png")
80-
with open(f"plot-{THEME}.html", "wb") as f:
81-
f.write(chart.render())
120+
cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{THEME}.png", dpi=72)
121+
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
122+
f.write(svg)

0 commit comments

Comments
 (0)