|
1 | 1 | """ pyplots.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import xml.etree.ElementTree as ET |
| 8 | + |
7 | 9 | import cairosvg |
8 | 10 | import pygal |
9 | 11 | from pygal.style import Style |
10 | 12 |
|
11 | 13 |
|
12 | 14 | # Data - Sales KPIs showing actual vs target with qualitative ranges |
13 | 15 | 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": "{}%"}, |
19 | 23 | ] |
20 | 24 |
|
21 | | -# Qualitative range thresholds as percentage of max |
22 | 25 | 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" |
24 | 38 |
|
25 | | -# Custom style for bullet chart with grayscale bands |
| 39 | +# Style: grayscale range bands + performance-coded bars + black target |
26 | 40 | custom_style = Style( |
27 | 41 | background="white", |
28 | 42 | plot_background="white", |
29 | 43 | foreground="#333333", |
30 | 44 | 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, |
45 | 54 | ) |
46 | 55 |
|
47 | | -# Create horizontal stacked bar chart for range bands |
48 | 56 | chart = pygal.HorizontalStackedBar( |
49 | 57 | width=4800, |
50 | 58 | height=2700, |
51 | | - title="bullet-basic · pygal · pyplots.ai", |
| 59 | + title="bullet-basic \u00b7 pygal \u00b7 pyplots.ai", |
52 | 60 | style=custom_style, |
53 | 61 | show_legend=True, |
54 | 62 | legend_at_bottom=True, |
55 | | - legend_box_size=28, |
| 63 | + legend_box_size=26, |
56 | 64 | print_values=False, |
| 65 | + print_zeroes=False, |
57 | 66 | show_y_guides=False, |
58 | 67 | 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, |
62 | 72 | x_title="Performance (% of Maximum)", |
63 | 73 | range=(0, 100), |
64 | 74 | ) |
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 | | - |
85 | 75 | 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) |
151 | 76 |
|
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