Skip to content

Commit 36aa1d7

Browse files
feat(pygal): implement icicle-basic (#2849)
## Implementation: `icicle-basic` - pygal Implements the **pygal** version of `icicle-basic`. **File:** `plots/icicle-basic/implementations/pygal.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20606632269)* --------- 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 82127f8 commit 36aa1d7

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
""" pyplots.ai
2+
icicle-basic: Basic Icicle Chart
3+
Library: pygal 3.1.0 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-30
5+
"""
6+
7+
import xml.etree.ElementTree as ET
8+
9+
import cairosvg
10+
import pygal
11+
from pygal.style import Style
12+
13+
14+
# Data: File system structure with folders and files
15+
# Format: (name, parent, value) - leaf nodes have values, internal nodes computed
16+
hierarchy_data = [
17+
("Root", None, 0),
18+
("Documents", "Root", 0),
19+
("Pictures", "Root", 0),
20+
("Music", "Root", 0),
21+
("Reports", "Documents", 0),
22+
("Letters", "Documents", 0),
23+
("Spreadsheets", "Documents", 0),
24+
("Photos", "Pictures", 0),
25+
("Screenshots", "Pictures", 0),
26+
("Icons", "Pictures", 0),
27+
("Albums", "Music", 0),
28+
("Playlists", "Music", 0),
29+
("Podcasts", "Music", 0),
30+
("Q1_Report", "Reports", 45),
31+
("Q2_Report", "Reports", 55),
32+
("Q3_Report", "Reports", 50),
33+
("Cover_Letter", "Letters", 25),
34+
("Resume", "Letters", 35),
35+
("Thank_You", "Letters", 20),
36+
("Budget", "Spreadsheets", 60),
37+
("Forecast", "Spreadsheets", 40),
38+
("Analysis", "Spreadsheets", 20),
39+
("Photo_1", "Photos", 65),
40+
("Photo_2", "Photos", 75),
41+
("Photo_3", "Photos", 60),
42+
("Screen_1", "Screenshots", 25),
43+
("Screen_2", "Screenshots", 25),
44+
("Icon_1", "Icons", 35),
45+
("Icon_2", "Icons", 35),
46+
("Rock", "Albums", 60),
47+
("Jazz", "Albums", 55),
48+
("Pop", "Albums", 65),
49+
("Favorites", "Playlists", 40),
50+
("Podcast_1", "Podcasts", 45),
51+
("Podcast_2", "Podcasts", 45),
52+
]
53+
54+
# Build tree structure
55+
nodes = {}
56+
children = {}
57+
58+
for name, parent, value in hierarchy_data:
59+
nodes[name] = {"name": name, "parent": parent, "value": value}
60+
if parent is not None:
61+
if parent not in children:
62+
children[parent] = []
63+
children[parent].append(name)
64+
65+
# Calculate total values for all nodes (bottom-up traversal)
66+
# Get nodes in depth order using BFS
67+
node_depths = {"Root": 0}
68+
queue = ["Root"]
69+
depth_order = []
70+
while queue:
71+
current = queue.pop(0)
72+
depth_order.append(current)
73+
if current in children:
74+
for child in children[current]:
75+
node_depths[child] = node_depths[current] + 1
76+
queue.append(child)
77+
78+
# Calculate values bottom-up
79+
node_values = {}
80+
for node_name in reversed(depth_order):
81+
if node_name not in children:
82+
node_values[node_name] = nodes[node_name]["value"]
83+
else:
84+
node_values[node_name] = sum(node_values[child] for child in children[node_name])
85+
86+
# Calculate positions for icicle chart (top-to-bottom layout)
87+
positions = {}
88+
positions["Root"] = {"x_start": 0, "x_end": 1, "depth": 0, "value": node_values["Root"]}
89+
90+
# Process nodes level by level
91+
for node_name in depth_order:
92+
if node_name in children:
93+
pos = positions[node_name]
94+
current_x = pos["x_start"]
95+
total_value = node_values[node_name]
96+
for child in children[node_name]:
97+
child_value = node_values[child]
98+
child_width = (child_value / total_value) * (pos["x_end"] - pos["x_start"])
99+
positions[child] = {
100+
"x_start": current_x,
101+
"x_end": current_x + child_width,
102+
"depth": pos["depth"] + 1,
103+
"value": child_value,
104+
}
105+
current_x += child_width
106+
107+
# Find max depth
108+
max_depth = max(pos["depth"] for pos in positions.values())
109+
110+
# Chart dimensions (landscape format for icicle chart)
111+
WIDTH = 4800
112+
HEIGHT = 2700
113+
MARGIN_TOP = 120
114+
MARGIN_BOTTOM = 100
115+
MARGIN_LEFT = 50
116+
MARGIN_RIGHT = 200 # Space for level labels
117+
PLOT_WIDTH = WIDTH - MARGIN_LEFT - MARGIN_RIGHT
118+
PLOT_HEIGHT = HEIGHT - MARGIN_TOP - MARGIN_BOTTOM
119+
120+
# Color palette by depth level (colorblind-safe)
121+
DEPTH_COLORS = [
122+
"#306998", # Python Blue - Level 0
123+
"#FFD43B", # Python Yellow - Level 1
124+
"#4ECDC4", # Teal - Level 2
125+
"#FF6B6B", # Coral - Level 3
126+
"#95E1D3", # Light teal - Level 4
127+
]
128+
129+
# Text colors for each depth (white on dark, black on light)
130+
TEXT_COLORS = ["white", "#333333", "#333333", "white", "#333333"]
131+
132+
# Use pygal Style for consistent theming
133+
custom_style = Style(
134+
background="white",
135+
plot_background="white",
136+
foreground="#333",
137+
foreground_strong="#333",
138+
foreground_subtle="#666",
139+
colors=DEPTH_COLORS,
140+
title_font_size=72,
141+
label_font_size=42,
142+
major_label_font_size=36,
143+
legend_font_size=36,
144+
font_family="sans-serif",
145+
)
146+
147+
# Create base pygal config (used for style extraction)
148+
config = pygal.Config()
149+
config.width = WIDTH
150+
config.height = HEIGHT
151+
config.style = custom_style
152+
153+
# Build SVG using standard library
154+
svg_ns = "http://www.w3.org/2000/svg"
155+
ET.register_namespace("", svg_ns)
156+
157+
svg_root = ET.Element("svg", xmlns=svg_ns, width=str(WIDTH), height=str(HEIGHT), viewBox=f"0 0 {WIDTH} {HEIGHT}")
158+
svg_root.set("style", f"background-color: {custom_style.background};")
159+
160+
# Add title
161+
title_elem = ET.SubElement(svg_root, "text")
162+
title_elem.set("x", str(WIDTH / 2))
163+
title_elem.set("y", "70")
164+
title_elem.set("text-anchor", "middle")
165+
title_elem.set("fill", custom_style.foreground_strong)
166+
title_elem.set("font-size", str(custom_style.title_font_size))
167+
title_elem.set("font-family", custom_style.font_family)
168+
title_elem.set("font-weight", "bold")
169+
title_elem.text = "icicle-basic · pygal · pyplots.ai"
170+
171+
# Create main group for rectangles
172+
g = ET.SubElement(svg_root, "g")
173+
g.set("class", "icicle-chart")
174+
175+
# Draw rectangles
176+
row_height = PLOT_HEIGHT / (max_depth + 1)
177+
gap = 3 # Small gap between rectangles
178+
179+
for node_name, pos in positions.items():
180+
depth = pos["depth"]
181+
x_start = pos["x_start"]
182+
x_end = pos["x_end"]
183+
width = x_end - x_start
184+
185+
# Calculate pixel positions
186+
px_x = MARGIN_LEFT + x_start * PLOT_WIDTH
187+
px_width = width * PLOT_WIDTH - gap
188+
px_y = MARGIN_TOP + depth * row_height
189+
px_height = row_height - gap
190+
191+
# Get color based on depth
192+
color = DEPTH_COLORS[depth % len(DEPTH_COLORS)]
193+
194+
# Create rectangle element
195+
rect = ET.SubElement(g, "rect")
196+
rect.set("x", f"{px_x:.1f}")
197+
rect.set("y", f"{px_y:.1f}")
198+
rect.set("width", f"{max(0, px_width):.1f}")
199+
rect.set("height", f"{px_height:.1f}")
200+
rect.set("fill", color)
201+
rect.set("fill-opacity", "0.85")
202+
rect.set("stroke", "white")
203+
rect.set("stroke-width", "2")
204+
205+
# Add tooltip
206+
title = ET.SubElement(rect, "title")
207+
title.text = f"{node_name.replace('_', ' ')}: {pos['value']}"
208+
209+
# Add label if rectangle is wide enough
210+
if px_width > 60:
211+
label = node_name.replace("_", " ")
212+
# Calculate max characters based on width
213+
max_chars = max(3, int(px_width / 22))
214+
if len(label) > max_chars:
215+
label = label[: max_chars - 2] + ".."
216+
217+
# Calculate font size based on width
218+
fontsize = min(36, max(18, int(px_width / 6)))
219+
220+
text = ET.SubElement(g, "text")
221+
text.set("x", f"{px_x + px_width / 2:.1f}")
222+
text.set("y", f"{px_y + px_height / 2 + fontsize / 3:.1f}")
223+
text.set("text-anchor", "middle")
224+
text.set("fill", TEXT_COLORS[depth % len(TEXT_COLORS)])
225+
text.set("font-size", str(fontsize))
226+
text.set("font-family", custom_style.font_family)
227+
text.set("font-weight", "bold")
228+
text.text = label
229+
230+
# Add depth level labels on the right
231+
level_labels = ["Root", "Category", "Subcategory", "Item", "Detail"]
232+
labels_g = ET.SubElement(svg_root, "g")
233+
labels_g.set("class", "level-labels")
234+
235+
for depth in range(max_depth + 1):
236+
y_pos = MARGIN_TOP + depth * row_height + row_height / 2
237+
level_label = level_labels[depth] if depth < len(level_labels) else f"Level {depth}"
238+
239+
text = ET.SubElement(labels_g, "text")
240+
text.set("x", str(MARGIN_LEFT + PLOT_WIDTH + 25))
241+
text.set("y", f"{y_pos + 10:.1f}")
242+
text.set("fill", custom_style.foreground_strong)
243+
text.set("font-size", str(custom_style.major_label_font_size))
244+
text.set("font-family", custom_style.font_family)
245+
text.text = level_label
246+
247+
# Add legend at bottom
248+
legend_y = HEIGHT - 50
249+
legend_items = [
250+
("Root", DEPTH_COLORS[0]),
251+
("Category", DEPTH_COLORS[1]),
252+
("Subcategory", DEPTH_COLORS[2]),
253+
("Item", DEPTH_COLORS[3]),
254+
]
255+
legend_x_start = WIDTH / 2 - 550
256+
257+
legend_g = ET.SubElement(svg_root, "g")
258+
legend_g.set("class", "legend")
259+
260+
for i, (label, color) in enumerate(legend_items):
261+
x = legend_x_start + i * 300
262+
# Rectangle marker
263+
marker = ET.SubElement(legend_g, "rect")
264+
marker.set("x", str(x))
265+
marker.set("y", str(legend_y - 15))
266+
marker.set("width", "30")
267+
marker.set("height", "30")
268+
marker.set("fill", color)
269+
marker.set("stroke", "#444")
270+
marker.set("stroke-width", "1")
271+
# Label
272+
lbl = ET.SubElement(legend_g, "text")
273+
lbl.set("x", str(x + 40))
274+
lbl.set("y", str(legend_y + 6))
275+
lbl.set("fill", custom_style.foreground_strong)
276+
lbl.set("font-size", str(custom_style.legend_font_size))
277+
lbl.set("font-family", custom_style.font_family)
278+
lbl.text = label
279+
280+
# Write SVG to file (pygal convention for interactive output)
281+
svg_output = ET.tostring(svg_root, encoding="unicode")
282+
with open("plot.html", "w") as f:
283+
f.write(svg_output)
284+
285+
# Render to PNG via cairosvg
286+
cairosvg.svg2png(bytestring=svg_output.encode("utf-8"), write_to="plot.png")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
library: pygal
2+
specification_id: icicle-basic
3+
created: '2025-12-30T21:52:27Z'
4+
updated: '2025-12-30T22:00:15Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20606632269
7+
issue: 0
8+
python_version: 3.13.11
9+
library_version: 3.1.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/icicle-basic/pygal/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/icicle-basic/pygal/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/icicle-basic/pygal/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent hierarchical visualization with clear parent-child relationships through
17+
spatial adjacency
18+
- Smart color scheme differentiating hierarchy levels with colorblind-safe palette
19+
- Good use of file system metaphor making the data immediately understandable
20+
- Adaptive label truncation prevents overlap while maintaining readability
21+
- Level labels on right side provide helpful context
22+
- Clean legend placement at bottom
23+
weaknesses:
24+
- Manual SVG construction is necessary but makes code more complex than typical
25+
pygal implementations
26+
- Some leaf nodes have very narrow rectangles making labels hard to read (e.g.,
27+
Cov.., Th..)

0 commit comments

Comments
 (0)