Skip to content

Commit c7b50ff

Browse files
feat(pygal): implement bullet-basic (#1042)
## Implementation: `bullet-basic` - pygal Implements the **pygal** version of `bullet-basic`. **File:** `plots/bullet-basic/implementations/pygal.py` **Parent Issue:** #999 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20257947709)* --------- 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 c98f606 commit c7b50ff

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""
2+
bullet-basic: Basic Bullet Chart
3+
Library: pygal
4+
5+
Pygal lacks a native bullet chart type. We use HorizontalStackedBar for qualitative
6+
ranges, then inject custom SVG elements for actual value bars and target markers.
7+
"""
8+
9+
import cairosvg
10+
import pygal
11+
from pygal.style import Style
12+
13+
14+
# Data - Sales KPIs showing actual vs target with qualitative ranges
15+
metrics = [
16+
{"label": "Revenue", "actual": 275, "target": 250, "max": 300, "unit": "$K"},
17+
{"label": "Profit", "actual": 85, "target": 100, "max": 120, "unit": "$K"},
18+
{"label": "New Orders", "actual": 320, "target": 350, "max": 400, "unit": ""},
19+
{"label": "Customers", "actual": 1450, "target": 1400, "max": 1600, "unit": ""},
20+
{"label": "Satisfaction", "actual": 4.2, "target": 4.5, "max": 5.0, "unit": "/5"},
21+
]
22+
23+
# Qualitative range thresholds as percentage of max
24+
POOR_PCT = 50
25+
SATISFACTORY_PCT = 75
26+
27+
# Custom style for bullet chart with grayscale bands
28+
custom_style = Style(
29+
background="white",
30+
plot_background="white",
31+
foreground="#333333",
32+
foreground_strong="#333333",
33+
foreground_subtle="#666666",
34+
colors=(
35+
"#E0E0E0", # Poor range (lightest)
36+
"#B8B8B8", # Satisfactory range
37+
"#909090", # Good range (darkest)
38+
"#306998", # Actual value (Python blue)
39+
"#1a1a1a", # Target marker (black)
40+
),
41+
title_font_size=72,
42+
label_font_size=42,
43+
major_label_font_size=40,
44+
legend_font_size=36,
45+
value_font_size=32,
46+
tooltip_font_size=32,
47+
)
48+
49+
# Create horizontal stacked bar chart for range bands
50+
chart = pygal.HorizontalStackedBar(
51+
width=4800,
52+
height=2700,
53+
title="bullet-basic · pygal · pyplots.ai",
54+
style=custom_style,
55+
show_legend=True,
56+
legend_at_bottom=True,
57+
legend_box_size=28,
58+
print_values=False,
59+
show_y_guides=False,
60+
show_x_guides=True,
61+
x_label_rotation=0,
62+
margin=80,
63+
spacing=40,
64+
x_title="Performance (% of Maximum)",
65+
range=(0, 100),
66+
)
67+
68+
# Build labels and range data
69+
labels = []
70+
poor_vals = []
71+
satisfactory_vals = []
72+
good_vals = []
73+
actual_pcts = []
74+
target_pcts = []
75+
76+
for m in metrics:
77+
actual_pct = (m["actual"] / m["max"]) * 100
78+
target_pct = (m["target"] / m["max"]) * 100
79+
labels.append(f"{m['label']} ({m['actual']}{m['unit']})")
80+
# Stacked segments for qualitative ranges
81+
poor_vals.append(POOR_PCT)
82+
satisfactory_vals.append(SATISFACTORY_PCT - POOR_PCT)
83+
good_vals.append(100 - SATISFACTORY_PCT)
84+
actual_pcts.append(actual_pct)
85+
target_pcts.append(target_pct)
86+
87+
chart.x_labels = labels
88+
chart.add("Poor (0-50%)", poor_vals)
89+
chart.add("Satisfactory (50-75%)", satisfactory_vals)
90+
chart.add("Good (75-100%)", good_vals)
91+
92+
# Render base chart to SVG, then inject actual bars and target markers
93+
svg_string = chart.render().decode("utf-8")
94+
95+
# Pygal plot coordinates (from SVG analysis):
96+
# Plot area: transform="translate(624, 192)", width=4096, height=2104
97+
# Bars start at x=78.77 within plot (total from origin: 624 + 78.77 = 702.77)
98+
# X-axis 0 maps to ~78.77, 100 maps to ~4017.23 (range of ~3938.46 pixels)
99+
PLOT_OFFSET_X = 624
100+
PLOT_OFFSET_Y = 192
101+
BAR_START_X = 78.77 # Within plot coordinates
102+
X_SCALE = 39.3846 # pixels per percentage point (3938.46 / 100)
103+
104+
# Row Y positions (from SVG: y values in <desc> tags) - relative to plot origin
105+
ROW_Y_CENTERS = [1861.23, 1456.62, 1052.0, 647.38, 242.77] # Revenue, Profit, Orders, Customers, Satisfaction
106+
107+
# Build custom SVG elements for actual bars and target markers
108+
custom_elements = []
109+
for i, (actual_pct, target_pct) in enumerate(zip(actual_pcts, target_pcts, strict=True)):
110+
# Calculate positions in plot coordinates
111+
y_center = PLOT_OFFSET_Y + ROW_Y_CENTERS[i]
112+
x_start = PLOT_OFFSET_X + BAR_START_X
113+
114+
# Actual value bar - narrower bar centered on row
115+
actual_width = actual_pct * X_SCALE
116+
bar_height = 100 # Thinner than the range bands
117+
custom_elements.append(
118+
f'<rect x="{x_start}" y="{y_center - bar_height / 2}" '
119+
f'width="{actual_width}" height="{bar_height}" fill="#306998"/>'
120+
)
121+
122+
# Target marker - thin vertical line extending beyond the bar
123+
target_x = x_start + target_pct * X_SCALE
124+
marker_height = 180
125+
marker_width = 12
126+
custom_elements.append(
127+
f'<rect x="{target_x - marker_width / 2}" y="{y_center - marker_height / 2}" '
128+
f'width="{marker_width}" height="{marker_height}" fill="#1a1a1a"/>'
129+
)
130+
131+
# Add legend entries for Actual and Target (positioned after existing legend items)
132+
legend_y = 2574 # Same line as existing legend
133+
legend_x_actual = 3200
134+
legend_x_target = 3700
135+
custom_elements.append(f'<rect x="{legend_x_actual}" y="{legend_y}" width="28" height="28" fill="#306998"/>')
136+
custom_elements.append(
137+
f'<text x="{legend_x_actual + 40}" y="{legend_y + 24}" '
138+
f'font-family="Consolas, monospace" font-size="36" fill="#333">Actual</text>'
139+
)
140+
custom_elements.append(f'<rect x="{legend_x_target}" y="{legend_y}" width="28" height="28" fill="#1a1a1a"/>')
141+
custom_elements.append(
142+
f'<text x="{legend_x_target + 40}" y="{legend_y + 24}" '
143+
f'font-family="Consolas, monospace" font-size="36" fill="#333">Target</text>'
144+
)
145+
146+
# Inject custom elements before closing </svg>
147+
injection = "\n".join(custom_elements)
148+
svg_output = svg_string.replace("</svg>", f"{injection}\n</svg>")
149+
150+
# Save SVG and PNG
151+
with open("plot.html", "w") as f:
152+
f.write(svg_output)
153+
154+
cairosvg.svg2png(bytestring=svg_output.encode(), write_to="plot.png")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Per-library metadata for pygal implementation of bullet-basic
2+
# Auto-generated by impl-generate.yml
3+
4+
library: pygal
5+
specification_id: bullet-basic
6+
7+
# Preview URLs (filled by workflow)
8+
preview_url: https://storage.googleapis.com/pyplots-images/plots/bullet-basic/pygal/plot.png
9+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/bullet-basic/pygal/plot_thumb.png
10+
preview_html: https://storage.googleapis.com/pyplots-images/plots/bullet-basic/pygal/plot.html
11+
12+
current:
13+
version: 0
14+
generated_at: 2025-12-16T05:56:05Z
15+
generated_by: claude-opus-4-5-20251101
16+
workflow_run: 20257947709
17+
issue: 999
18+
quality_score: 91
19+
# Version info (filled by workflow)
20+
python_version: "3.13.11"
21+
library_version: "unknown"
22+
23+
history: []

0 commit comments

Comments
 (0)