|
1 | 1 | """ anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
| 8 | +import re |
8 | 9 |
|
| 10 | +import cairosvg |
9 | 11 | import pygal |
10 | 12 | from pygal.style import Style |
11 | 13 |
|
|
15 | 17 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
16 | 18 | INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
17 | 19 |
|
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") |
19 | 31 |
|
20 | 32 | custom_style = Style( |
21 | 33 | background=PAGE_BG, |
|
24 | 36 | foreground_strong=INK, |
25 | 37 | foreground_subtle=INK_MUTED, |
26 | 38 | 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, |
33 | 46 | ) |
34 | 47 |
|
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. |
38 | 73 | departments = [ |
39 | 74 | ( |
40 | 75 | "Technology", |
41 | | - "#009E73", |
42 | 76 | [ |
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), |
46 | 80 | ], |
47 | 81 | ), |
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 | + ), |
51 | 88 | ] |
52 | 89 |
|
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) |
55 | 90 | chart = pygal.Pie( |
56 | 91 | 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, |
64 | 97 | print_values=False, |
| 98 | + margin=380, |
65 | 99 | ) |
66 | 100 |
|
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) |
72 | 106 |
|
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, |
77 | 118 | ) |
78 | 119 |
|
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