|
3 | 3 | Library: pygal |
4 | 4 | """ |
5 | 5 |
|
| 6 | +import re |
| 7 | + |
| 8 | +import cairosvg |
6 | 9 | import pygal |
7 | 10 | from pygal.style import Style |
8 | 11 |
|
|
19 | 22 | TOTAL_COLOR = "#306998" # Python Blue for totals |
20 | 23 | INCREASE_COLOR = "#4CAF50" # Green for increases |
21 | 24 | DECREASE_COLOR = "#E53935" # Red for decreases |
| 25 | +CONNECTOR_COLOR = "#888888" # Gray for connecting lines |
22 | 26 |
|
23 | | -# Custom style for waterfall chart - colors match series order: base, total, increase, decrease |
| 27 | +# Custom style for waterfall chart - colors match series order |
24 | 28 | custom_style = Style( |
25 | 29 | background="white", |
26 | 30 | plot_background="white", |
27 | 31 | foreground="#333333", |
28 | 32 | foreground_strong="#333333", |
29 | 33 | foreground_subtle="#666666", |
30 | | - colors=("rgba(255,255,255,0)", TOTAL_COLOR, INCREASE_COLOR, DECREASE_COLOR), |
| 34 | + colors=("rgba(255,255,255,0)", TOTAL_COLOR, INCREASE_COLOR, DECREASE_COLOR, CONNECTOR_COLOR), |
31 | 35 | title_font_size=48, |
32 | 36 | label_font_size=36, |
33 | 37 | major_label_font_size=36, |
|
60 | 64 | ) |
61 | 65 | cumulative += val |
62 | 66 |
|
| 67 | + |
| 68 | +# Custom value formatter - shows absolute height (labels handle signs separately) |
| 69 | +def format_value(x): |
| 70 | + """Format value for display.""" |
| 71 | + if x is None or abs(x) < 0.01: |
| 72 | + return "" |
| 73 | + return f"${x:,.0f}" |
| 74 | + |
| 75 | + |
63 | 76 | # Create a stacked bar chart - first stack is invisible base, second is visible bar |
64 | 77 | chart = pygal.StackedBar( |
65 | 78 | width=4800, |
|
71 | 84 | show_legend=True, |
72 | 85 | legend_at_bottom=True, |
73 | 86 | legend_box_size=30, |
74 | | - print_values=True, |
75 | | - value_formatter=lambda x: f"${x:,.0f}" if x and abs(x) > 0.01 else "", |
| 87 | + print_values=False, |
| 88 | + print_labels=True, # Use labels instead of values for proper sign display |
| 89 | + value_formatter=format_value, |
76 | 90 | show_y_guides=True, |
77 | 91 | show_x_guides=False, |
78 | 92 | margin=50, |
|
87 | 101 | total_series = [] |
88 | 102 | increase_series = [] |
89 | 103 | decrease_series = [] |
| 104 | +# Track cumulative values for connector lines |
| 105 | +connector_levels = [] |
90 | 106 |
|
91 | 107 | for bar in bar_data: |
92 | 108 | base_series.append({"value": bar["base"], "color": "rgba(255,255,255,0)"}) |
93 | 109 |
|
| 110 | + # Get the original change value for proper display |
| 111 | + original_value = bar["value"] |
| 112 | + |
94 | 113 | if bar["color"] == TOTAL_COLOR: |
95 | | - total_series.append({"value": bar["height"], "color": TOTAL_COLOR}) |
| 114 | + # Format totals with positive values |
| 115 | + total_series.append({"value": bar["height"], "color": TOTAL_COLOR, "label": f"${bar['height']:,.0f}"}) |
96 | 116 | increase_series.append({"value": None}) |
97 | 117 | decrease_series.append({"value": None}) |
| 118 | + connector_levels.append(bar["height"]) |
98 | 119 | elif bar["color"] == INCREASE_COLOR: |
99 | 120 | total_series.append({"value": None}) |
100 | | - increase_series.append({"value": bar["height"], "color": INCREASE_COLOR}) |
| 121 | + # Positive changes show with positive label |
| 122 | + increase_series.append({"value": bar["height"], "color": INCREASE_COLOR, "label": f"+${original_value:,.0f}"}) |
101 | 123 | decrease_series.append({"value": None}) |
| 124 | + connector_levels.append(bar["base"] + bar["height"]) |
102 | 125 | else: |
103 | 126 | total_series.append({"value": None}) |
104 | 127 | increase_series.append({"value": None}) |
105 | | - decrease_series.append({"value": bar["height"], "color": DECREASE_COLOR}) |
| 128 | + # Negative changes show with negative sign |
| 129 | + decrease_series.append( |
| 130 | + {"value": bar["height"], "color": DECREASE_COLOR, "label": f"-${abs(original_value):,.0f}"} |
| 131 | + ) |
| 132 | + connector_levels.append(bar["base"]) # Top of decrease bar after the drop |
106 | 133 |
|
107 | 134 | # Add series - base is invisible spacer (no legend entry) |
108 | 135 | # Check if we have any increases to show in legend |
|
114 | 141 | chart.add("Increase", increase_series) |
115 | 142 | chart.add("Decrease", decrease_series) |
116 | 143 |
|
117 | | -# Save outputs |
118 | | -chart.render_to_png("plot.png") |
119 | | -chart.render_to_file("plot.html") |
| 144 | +# Render the base SVG |
| 145 | +base_svg = chart.render().decode("utf-8") |
| 146 | + |
| 147 | +# Create connector lines by injecting SVG elements |
| 148 | +# Parse the SVG to find bar positions and add horizontal connector lines |
| 149 | +# Extract y-axis scaling from the chart to calculate line positions |
| 150 | + |
| 151 | +# Find the plot area boundaries from the SVG |
| 152 | +# The y-axis needs to be scaled: find min/max y values and their pixel positions |
| 153 | +y_max = max(bar["base"] + bar["height"] for bar in bar_data) |
| 154 | +y_min = 0 |
| 155 | + |
| 156 | +# Look for the plot area group and calculate bar positions |
| 157 | +# Pygal uses specific class names for the plot area |
| 158 | +# We'll add connector lines as a new group after the bars |
| 159 | + |
| 160 | +# Calculate approximate bar center x positions based on category count |
| 161 | +num_bars = len(categories) |
| 162 | + |
| 163 | +# Create connector line SVG elements |
| 164 | +# Connector lines go from the top of one bar to the start level of the next bar |
| 165 | +connector_lines = [] |
| 166 | +for i in range(num_bars - 1): |
| 167 | + # Each connector goes from current bar top to next bar's starting cumulative level |
| 168 | + current_top = connector_levels[i] |
| 169 | + # Use current top as the horizontal line level (connecting to next bar) |
| 170 | + connector_lines.append((i, current_top)) |
| 171 | + |
| 172 | +# Alternative approach: Use secondary_range or custom rendering |
| 173 | +# For a clean solution, render connector lines as a line series overlay |
| 174 | + |
| 175 | +# Render the HTML with embedded connector line visualization |
| 176 | +# Add connector data as a secondary visualization in the HTML output |
| 177 | +html_content = f"""<!DOCTYPE html> |
| 178 | +<html> |
| 179 | +<head> |
| 180 | + <meta charset="utf-8"> |
| 181 | + <title>waterfall-basic · pygal · pyplots.ai</title> |
| 182 | + <style> |
| 183 | + .connector-line {{ |
| 184 | + stroke: {CONNECTOR_COLOR}; |
| 185 | + stroke-width: 3; |
| 186 | + stroke-dasharray: 10, 5; |
| 187 | + }} |
| 188 | + </style> |
| 189 | +</head> |
| 190 | +<body> |
| 191 | + {base_svg} |
| 192 | + <script> |
| 193 | + // Add connector lines after chart renders |
| 194 | + document.addEventListener('DOMContentLoaded', function() {{ |
| 195 | + var svg = document.querySelector('svg'); |
| 196 | + if (!svg) return; |
| 197 | +
|
| 198 | + // Get the plot area dimensions |
| 199 | + var plotArea = svg.querySelector('.plot'); |
| 200 | + if (!plotArea) return; |
| 201 | +
|
| 202 | + var rect = plotArea.getBBox(); |
| 203 | + var barWidth = rect.width / {num_bars}; |
| 204 | +
|
| 205 | + // Connector levels (cumulative values) from Python |
| 206 | + var levels = {connector_levels}; |
| 207 | + var yMax = {y_max}; |
| 208 | +
|
| 209 | + // Calculate y scale |
| 210 | + var yScale = rect.height / yMax; |
| 211 | +
|
| 212 | + // Add connector lines |
| 213 | + var ns = 'http://www.w3.org/2000/svg'; |
| 214 | + var connectorGroup = document.createElementNS(ns, 'g'); |
| 215 | + connectorGroup.setAttribute('class', 'connectors'); |
| 216 | +
|
| 217 | + for (var i = 0; i < levels.length - 1; i++) {{ |
| 218 | + var line = document.createElementNS(ns, 'line'); |
| 219 | + var x1 = rect.x + (i + 0.5) * barWidth + barWidth * 0.35; |
| 220 | + var x2 = rect.x + (i + 1.5) * barWidth - barWidth * 0.35; |
| 221 | + var y = rect.y + rect.height - levels[i] * yScale; |
| 222 | +
|
| 223 | + line.setAttribute('x1', x1); |
| 224 | + line.setAttribute('y1', y); |
| 225 | + line.setAttribute('x2', x2); |
| 226 | + line.setAttribute('y2', y); |
| 227 | + line.setAttribute('class', 'connector-line'); |
| 228 | + connectorGroup.appendChild(line); |
| 229 | + }} |
| 230 | +
|
| 231 | + plotArea.appendChild(connectorGroup); |
| 232 | + }}); |
| 233 | + </script> |
| 234 | +</body> |
| 235 | +</html>""" |
| 236 | + |
| 237 | +# For PNG output, we need to add connector lines directly to the SVG |
| 238 | +# Parse SVG and inject lines before rendering to PNG |
| 239 | + |
| 240 | + |
| 241 | +def add_connector_lines_to_svg(svg_content, bar_data, connector_levels): |
| 242 | + """Add horizontal connector lines between bars in the SVG.""" |
| 243 | + # Parse the actual plot dimensions from pygal's SVG |
| 244 | + # Plot group is at translate(350, 138) with width 4399.2 and height 2218.0 |
| 245 | + plot_translate_match = re.search(r'translate\(([0-9.]+),\s*([0-9.]+)\)"\s*class="plot"', svg_content) |
| 246 | + plot_bg_match = re.search( |
| 247 | + r'class="plot"[^>]*>.*?<rect[^>]*width="([0-9.]+)"[^>]*height="([0-9.]+)"', svg_content, re.DOTALL |
| 248 | + ) |
| 249 | + |
| 250 | + # Default values based on pygal's typical 4800x2700 layout |
| 251 | + plot_x = 350 |
| 252 | + plot_y = 138 |
| 253 | + plot_width = 4399.2 |
| 254 | + |
| 255 | + if plot_translate_match: |
| 256 | + plot_x = float(plot_translate_match.group(1)) |
| 257 | + plot_y = float(plot_translate_match.group(2)) |
| 258 | + |
| 259 | + if plot_bg_match: |
| 260 | + plot_width = float(plot_bg_match.group(1)) |
| 261 | + |
| 262 | + # Calculate y-axis range from data |
| 263 | + y_max = max(bd["base"] + bd["height"] for bd in bar_data) |
| 264 | + |
| 265 | + # Build connector line elements |
| 266 | + num_bars = len(connector_levels) |
| 267 | + bar_width = plot_width / num_bars |
| 268 | + |
| 269 | + # Extract actual y-axis range from the SVG guides |
| 270 | + # Default padding values based on typical pygal layout |
| 271 | + y_axis_top = 42.65 # Y coordinate for max value |
| 272 | + y_axis_bottom = 2175.35 # Y coordinate for zero |
| 273 | + |
| 274 | + # Extract y positions from guides if possible |
| 275 | + guides = re.findall(r'path d="M0\.000000 ([0-9.]+) h[^"]*" class="(?:major )?(?:guide )?line"', svg_content) |
| 276 | + if guides: |
| 277 | + y_axis_top = float(min(guides, key=float)) |
| 278 | + y_axis_bottom = float(max(guides, key=float)) |
| 279 | + |
| 280 | + y_axis_range = y_axis_bottom - y_axis_top |
| 281 | + |
| 282 | + # Create connector group with transform to match plot area |
| 283 | + lines_svg = f'<g class="connectors" transform="translate({plot_x}, {plot_y})" stroke="{CONNECTOR_COLOR}" stroke-width="6" stroke-dasharray="20,10">\n' |
| 284 | + |
| 285 | + # Y scale: map data values to SVG coordinates (inverted, origin at top) |
| 286 | + # y=0 in data maps to y_axis_bottom, y=y_max maps to y_axis_top |
| 287 | + def data_to_svg_y(value): |
| 288 | + return y_axis_bottom - (value / y_max) * y_axis_range |
| 289 | + |
| 290 | + # Add horizontal connector lines between consecutive bars |
| 291 | + # Each line goes from right edge of current bar to left edge of next bar |
| 292 | + for i in range(num_bars - 1): |
| 293 | + level = connector_levels[i] |
| 294 | + # Bar center positions within plot area: (i + 0.5) * bar_width |
| 295 | + # Line starts at right side of bar i and ends at left side of bar i+1 |
| 296 | + bar_center_i = (i + 0.5) * bar_width |
| 297 | + bar_center_next = (i + 1.5) * bar_width |
| 298 | + # Approximate bar half-width (with spacing) |
| 299 | + bar_half_width = bar_width * 0.4 |
| 300 | + |
| 301 | + x1 = bar_center_i + bar_half_width # Right edge of current bar |
| 302 | + x2 = bar_center_next - bar_half_width # Left edge of next bar |
| 303 | + y = data_to_svg_y(level) |
| 304 | + |
| 305 | + lines_svg += f' <line x1="{x1:.1f}" y1="{y:.1f}" x2="{x2:.1f}" y2="{y:.1f}"/>\n' |
| 306 | + |
| 307 | + lines_svg += "</g>\n" |
| 308 | + |
| 309 | + # Insert before closing </svg> |
| 310 | + svg_content = svg_content.replace("</svg>", lines_svg + "</svg>") |
| 311 | + |
| 312 | + return svg_content |
| 313 | + |
| 314 | + |
| 315 | +# Render SVG with connector lines |
| 316 | +svg_with_connectors = add_connector_lines_to_svg(base_svg, bar_data, connector_levels) |
| 317 | + |
| 318 | +# Save SVG with connectors |
| 319 | +with open("plot_with_connectors.svg", "w") as f: |
| 320 | + f.write(svg_with_connectors) |
| 321 | + |
| 322 | +# Render to PNG using cairosvg |
| 323 | +cairosvg.svg2png(bytestring=svg_with_connectors.encode("utf-8"), write_to="plot.png") |
| 324 | + |
| 325 | +# Save HTML with interactive connectors |
| 326 | +with open("plot.html", "w") as f: |
| 327 | + f.write(html_content) |
0 commit comments