|
| 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") |
0 commit comments