Skip to content

Commit 0fb6146

Browse files
fix(pygal): address review feedback for waterfall-basic
- Add connecting lines between bars to emphasize cumulative flow - Fix value labels to show proper negative signs on decrease bars - Extract y-axis coordinates from SVG for precise connector positioning Attempt 1/3 - fixes based on AI review
1 parent 43fe3b8 commit 0fb6146

1 file changed

Lines changed: 218 additions & 10 deletions

File tree

  • plots/waterfall-basic/implementations

plots/waterfall-basic/implementations/pygal.py

Lines changed: 218 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
Library: pygal
44
"""
55

6+
import re
7+
8+
import cairosvg
69
import pygal
710
from pygal.style import Style
811

@@ -19,15 +22,16 @@
1922
TOTAL_COLOR = "#306998" # Python Blue for totals
2023
INCREASE_COLOR = "#4CAF50" # Green for increases
2124
DECREASE_COLOR = "#E53935" # Red for decreases
25+
CONNECTOR_COLOR = "#888888" # Gray for connecting lines
2226

23-
# Custom style for waterfall chart - colors match series order: base, total, increase, decrease
27+
# Custom style for waterfall chart - colors match series order
2428
custom_style = Style(
2529
background="white",
2630
plot_background="white",
2731
foreground="#333333",
2832
foreground_strong="#333333",
2933
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),
3135
title_font_size=48,
3236
label_font_size=36,
3337
major_label_font_size=36,
@@ -60,6 +64,15 @@
6064
)
6165
cumulative += val
6266

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+
6376
# Create a stacked bar chart - first stack is invisible base, second is visible bar
6477
chart = pygal.StackedBar(
6578
width=4800,
@@ -71,8 +84,9 @@
7184
show_legend=True,
7285
legend_at_bottom=True,
7386
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,
7690
show_y_guides=True,
7791
show_x_guides=False,
7892
margin=50,
@@ -87,22 +101,35 @@
87101
total_series = []
88102
increase_series = []
89103
decrease_series = []
104+
# Track cumulative values for connector lines
105+
connector_levels = []
90106

91107
for bar in bar_data:
92108
base_series.append({"value": bar["base"], "color": "rgba(255,255,255,0)"})
93109

110+
# Get the original change value for proper display
111+
original_value = bar["value"]
112+
94113
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}"})
96116
increase_series.append({"value": None})
97117
decrease_series.append({"value": None})
118+
connector_levels.append(bar["height"])
98119
elif bar["color"] == INCREASE_COLOR:
99120
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}"})
101123
decrease_series.append({"value": None})
124+
connector_levels.append(bar["base"] + bar["height"])
102125
else:
103126
total_series.append({"value": None})
104127
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
106133

107134
# Add series - base is invisible spacer (no legend entry)
108135
# Check if we have any increases to show in legend
@@ -114,6 +141,187 @@
114141
chart.add("Increase", increase_series)
115142
chart.add("Decrease", decrease_series)
116143

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

Comments
 (0)