Skip to content

Commit b634f35

Browse files
update(bullet-basic): pygal — comprehensive quality review (#4346)
## Summary Updated **pygal** implementation for **bullet-basic** (Basic Bullet Chart). **Changes:** Comprehensive quality review improving SVG output, styling, and library features. ### Changes - Better SVG output and styling - Improved visual hierarchy - Enhanced library feature usage ## Test Plan - [x] Preview images uploaded to GCS staging - [x] Implementation file passes ruff format/check - [x] Metadata YAML updated with current versions - [ ] Automated review triggered --- Generated with [Claude Code](https://claude.com/claude-code) `/update` command --------- 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 ef39dfa commit b634f35

2 files changed

Lines changed: 263 additions & 239 deletions

File tree

Lines changed: 108 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,144 @@
11
""" pyplots.ai
22
bullet-basic: Basic Bullet Chart
3-
Library: pygal 3.1.0 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 85/100 | Updated: 2026-02-22
55
"""
66

7+
import xml.etree.ElementTree as ET
8+
79
import cairosvg
810
import pygal
911
from pygal.style import Style
1012

1113

1214
# Data - Sales KPIs showing actual vs target with qualitative ranges
1315
metrics = [
14-
{"label": "Revenue", "actual": 275, "target": 250, "max": 300, "unit": "$K"},
15-
{"label": "Profit", "actual": 85, "target": 100, "max": 120, "unit": "$K"},
16-
{"label": "New Orders", "actual": 320, "target": 350, "max": 400, "unit": ""},
17-
{"label": "Customers", "actual": 1450, "target": 1400, "max": 1600, "unit": ""},
18-
{"label": "Satisfaction", "actual": 4.2, "target": 4.5, "max": 5.0, "unit": "/5"},
16+
{"label": "Revenue", "actual": 275, "target": 250, "max": 300, "fmt": "${}K"},
17+
{"label": "Profit", "actual": 85, "target": 100, "max": 120, "fmt": "${}K"},
18+
{"label": "New Orders", "actual": 320, "target": 350, "max": 400, "fmt": "{}"},
19+
{"label": "Customers", "actual": 1450, "target": 1400, "max": 1600, "fmt": "{}"},
20+
{"label": "Satisfaction", "actual": 4.2, "target": 4.5, "max": 5.0, "fmt": "{}/5"},
21+
{"label": "Avg Deal Size", "actual": 42, "target": 50, "max": 60, "fmt": "${}K"},
22+
{"label": "Retention", "actual": 92, "target": 85, "max": 100, "fmt": "{}%"},
1923
]
2024

21-
# Qualitative range thresholds as percentage of max
2225
POOR_PCT = 50
23-
SATISFACTORY_PCT = 75
26+
SAT_PCT = 75
27+
28+
# Normalize to percentages and classify performance vs target
29+
actual_pcts = [round((m["actual"] / m["max"]) * 100, 1) for m in metrics]
30+
target_pcts = [round((m["target"] / m["max"]) * 100, 1) for m in metrics]
31+
above_target = [m["actual"] >= m["target"] for m in metrics]
32+
labels = [f"{m['label']} ({m['fmt'].format(m['actual'])})" for m in metrics]
33+
34+
# Performance-coded colors for data storytelling (colorblind-safe teal vs amber)
35+
COLOR_ABOVE = "#2A9D8F"
36+
COLOR_BELOW = "#D4770B"
37+
COLOR_TARGET = "#1a1a1a"
2438

25-
# Custom style for bullet chart with grayscale bands
39+
# Style: grayscale range bands + performance-coded bars + black target
2640
custom_style = Style(
2741
background="white",
2842
plot_background="white",
2943
foreground="#333333",
3044
foreground_strong="#333333",
31-
foreground_subtle="#666666",
32-
colors=(
33-
"#E0E0E0", # Poor range (lightest)
34-
"#B8B8B8", # Satisfactory range
35-
"#909090", # Good range (darkest)
36-
"#306998", # Actual value (Python blue)
37-
"#1a1a1a", # Target marker (black)
38-
),
39-
title_font_size=72,
40-
label_font_size=42,
41-
major_label_font_size=40,
42-
legend_font_size=36,
43-
value_font_size=32,
44-
tooltip_font_size=32,
45+
foreground_subtle="#999999",
46+
font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
47+
colors=("#E0E0E0", "#BFBFBF", "#969696", COLOR_ABOVE, COLOR_BELOW, COLOR_TARGET),
48+
title_font_size=64,
49+
label_font_size=40,
50+
major_label_font_size=36,
51+
legend_font_size=34,
52+
value_font_size=30,
53+
tooltip_font_size=30,
4554
)
4655

47-
# Create horizontal stacked bar chart for range bands
4856
chart = pygal.HorizontalStackedBar(
4957
width=4800,
5058
height=2700,
51-
title="bullet-basic · pygal · pyplots.ai",
59+
title="bullet-basic \u00b7 pygal \u00b7 pyplots.ai",
5260
style=custom_style,
5361
show_legend=True,
5462
legend_at_bottom=True,
55-
legend_box_size=28,
63+
legend_box_size=26,
5664
print_values=False,
65+
print_zeroes=False,
5766
show_y_guides=False,
5867
show_x_guides=True,
59-
x_label_rotation=0,
60-
margin=80,
61-
spacing=40,
68+
margin=40,
69+
spacing=0,
70+
rounded_bars=2,
71+
truncate_label=-1,
6272
x_title="Performance (% of Maximum)",
6373
range=(0, 100),
6474
)
65-
66-
# Build labels and range data
67-
labels = []
68-
poor_vals = []
69-
satisfactory_vals = []
70-
good_vals = []
71-
actual_pcts = []
72-
target_pcts = []
73-
74-
for m in metrics:
75-
actual_pct = (m["actual"] / m["max"]) * 100
76-
target_pct = (m["target"] / m["max"]) * 100
77-
labels.append(f"{m['label']} ({m['actual']}{m['unit']})")
78-
# Stacked segments for qualitative ranges
79-
poor_vals.append(POOR_PCT)
80-
satisfactory_vals.append(SATISFACTORY_PCT - POOR_PCT)
81-
good_vals.append(100 - SATISFACTORY_PCT)
82-
actual_pcts.append(actual_pct)
83-
target_pcts.append(target_pct)
84-
8575
chart.x_labels = labels
86-
chart.add("Poor (0-50%)", poor_vals)
87-
chart.add("Satisfactory (50-75%)", satisfactory_vals)
88-
chart.add("Good (75-100%)", good_vals)
89-
90-
# Render base chart to SVG, then inject actual bars and target markers
91-
svg_string = chart.render().decode("utf-8")
92-
93-
# Pygal plot coordinates (from SVG analysis):
94-
# Plot area: transform="translate(624, 192)", width=4096, height=2104
95-
# Bars start at x=78.77 within plot (total from origin: 624 + 78.77 = 702.77)
96-
# X-axis 0 maps to ~78.77, 100 maps to ~4017.23 (range of ~3938.46 pixels)
97-
PLOT_OFFSET_X = 624
98-
PLOT_OFFSET_Y = 192
99-
BAR_START_X = 78.77 # Within plot coordinates
100-
X_SCALE = 39.3846 # pixels per percentage point (3938.46 / 100)
101-
102-
# Row Y positions (from SVG: y values in <desc> tags) - relative to plot origin
103-
ROW_Y_CENTERS = [1861.23, 1456.62, 1052.0, 647.38, 242.77]
104-
105-
# Build custom SVG elements for actual bars and target markers
106-
custom_elements = []
107-
for i, (actual_pct, target_pct) in enumerate(zip(actual_pcts, target_pcts, strict=True)):
108-
# Calculate positions in plot coordinates
109-
y_center = PLOT_OFFSET_Y + ROW_Y_CENTERS[i]
110-
x_start = PLOT_OFFSET_X + BAR_START_X
111-
112-
# Actual value bar - narrower bar centered on row
113-
actual_width = actual_pct * X_SCALE
114-
bar_height = 100 # Thinner than the range bands
115-
custom_elements.append(
116-
f'<rect x="{x_start}" y="{y_center - bar_height / 2}" '
117-
f'width="{actual_width}" height="{bar_height}" fill="#306998"/>'
118-
)
119-
120-
# Target marker - thin vertical line extending beyond the bar
121-
target_x = x_start + target_pct * X_SCALE
122-
marker_height = 180
123-
marker_width = 12
124-
custom_elements.append(
125-
f'<rect x="{target_x - marker_width / 2}" y="{y_center - marker_height / 2}" '
126-
f'width="{marker_width}" height="{marker_height}" fill="#1a1a1a"/>'
127-
)
128-
129-
# Add legend entries for Actual and Target (positioned after existing legend items)
130-
legend_y = 2574 # Same line as existing legend
131-
legend_x_actual = 3200
132-
legend_x_target = 3700
133-
custom_elements.append(f'<rect x="{legend_x_actual}" y="{legend_y}" width="28" height="28" fill="#306998"/>')
134-
custom_elements.append(
135-
f'<text x="{legend_x_actual + 40}" y="{legend_y + 24}" '
136-
f'font-family="Consolas, monospace" font-size="36" fill="#333">Actual</text>'
137-
)
138-
custom_elements.append(f'<rect x="{legend_x_target}" y="{legend_y}" width="28" height="28" fill="#1a1a1a"/>')
139-
custom_elements.append(
140-
f'<text x="{legend_x_target + 40}" y="{legend_y + 24}" '
141-
f'font-family="Consolas, monospace" font-size="36" fill="#333">Target</text>'
142-
)
143-
144-
# Inject custom elements before closing </svg>
145-
injection = "\n".join(custom_elements)
146-
svg_output = svg_string.replace("</svg>", f"{injection}\n</svg>")
147-
148-
# Save SVG and PNG
149-
with open("plot.html", "w") as f:
150-
f.write(svg_output)
15176

152-
cairosvg.svg2png(bytestring=svg_output.encode(), write_to="plot.png")
77+
# Qualitative range bands as stacked series with per-value config dicts
78+
chart.add("Poor (0-50%)", [{"value": POOR_PCT, "label": labels[i]} for i in range(len(metrics))])
79+
chart.add("Satisfactory (50-75%)", [{"value": SAT_PCT - POOR_PCT, "label": labels[i]} for i in range(len(metrics))])
80+
chart.add("Good (75-100%)", [{"value": 100 - SAT_PCT, "label": labels[i]} for i in range(len(metrics))])
81+
82+
# Legend-only series for performance-coded actual bars and target marker
83+
chart.add("Above Target", [None] * len(metrics))
84+
chart.add("Below Target", [None] * len(metrics))
85+
chart.add("Target", [None] * len(metrics))
86+
87+
# Render SVG and parse for programmatic element injection
88+
ET.register_namespace("", "http://www.w3.org/2000/svg")
89+
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
90+
svg_bytes = chart.render()
91+
root = ET.fromstring(svg_bytes)
92+
NS = "http://www.w3.org/2000/svg"
93+
94+
parent_map = {child: parent for parent in root.iter() for child in parent}
95+
96+
# Remove dashed leader lines for cleaner appearance
97+
for line in list(root.iter(f"{{{NS}}}line")):
98+
if line.get("stroke-dasharray"):
99+
p = parent_map.get(line)
100+
if p is not None:
101+
p.remove(line)
102+
103+
# Locate serie-0 (Poor range) bars as coordinate reference
104+
serie_0 = next((g for g in root.iter(f"{{{NS}}}g") if "serie-0" in g.get("class", "")), None)
105+
106+
# Extract bar positions from Poor range (x, y, width, height per metric row)
107+
poor_bars = []
108+
if serie_0 is not None:
109+
for rect in serie_0.iter(f"{{{NS}}}rect"):
110+
w, h = float(rect.get("width", "0")), float(rect.get("height", "0"))
111+
if w > 1 and h > 1:
112+
poor_bars.append((float(rect.get("x")), float(rect.get("y")), w, h))
113+
114+
# Inject actual bars and target markers into the plot coordinate space
115+
inject_parent = parent_map.get(serie_0, root)
116+
for i, (bx, by, bw, bh) in enumerate(poor_bars):
117+
# Convert percentage to pixel width using Poor band as scale reference
118+
px_per_pct = bw / POOR_PCT
119+
cy = by + bh / 2 # vertical center of this metric row
120+
121+
# Actual value bar (42% of band height for classic bullet chart layering)
122+
actual_w = actual_pcts[i] * px_per_pct
123+
bar_h = bh * 0.42
124+
bar_color = COLOR_ABOVE if above_target[i] else COLOR_BELOW
125+
a = ET.SubElement(inject_parent, f"{{{NS}}}rect")
126+
a.set("x", f"{bx:.1f}")
127+
a.set("y", f"{cy - bar_h / 2:.1f}")
128+
a.set("width", f"{actual_w:.1f}")
129+
a.set("height", f"{bar_h:.1f}")
130+
a.set("fill", bar_color)
131+
a.set("rx", "2")
132+
133+
# Target marker (prominent vertical line at target percentage)
134+
tx = bx + target_pcts[i] * px_per_pct
135+
marker_h = bh * 0.75
136+
t = ET.SubElement(inject_parent, f"{{{NS}}}rect")
137+
t.set("x", f"{tx - 6:.1f}")
138+
t.set("y", f"{cy - marker_h / 2:.1f}")
139+
t.set("width", "12")
140+
t.set("height", f"{marker_h:.1f}")
141+
t.set("fill", COLOR_TARGET)
142+
143+
# Save as PNG at native 4800×2700 resolution
144+
cairosvg.svg2png(bytestring=ET.tostring(root, encoding="utf-8"), write_to="plot.png")

0 commit comments

Comments
 (0)