|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | bullet-basic: Basic Bullet Chart |
3 | | -Library: matplotlib 3.10.8 | Python 3.13.11 |
4 | | -Quality: 92/100 | Created: 2025-12-23 |
| 3 | +Library: matplotlib 3.10.8 | Python 3.14.3 |
| 4 | +Quality: /100 | Updated: 2026-02-22 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import matplotlib.patheffects as pe |
7 | 8 | import matplotlib.pyplot as plt |
8 | 9 | from matplotlib.lines import Line2D |
9 | | -from matplotlib.patches import Patch |
| 10 | +from matplotlib.patches import FancyBboxPatch, Patch |
10 | 11 |
|
11 | 12 |
|
12 | | -# Data - Multiple KPIs with actual values, targets, and qualitative ranges |
| 13 | +# Data - Quarterly KPI dashboard with percentage-based metrics for consistent comparison |
13 | 14 | metrics = [ |
14 | | - {"label": "Revenue", "actual": 275, "target": 250, "ranges": [150, 200, 300], "unit": "$K"}, |
15 | | - {"label": "Profit", "actual": 45, "target": 50, "ranges": [20, 40, 60], "unit": "%"}, |
16 | | - {"label": "New Customers", "actual": 85, "target": 100, "ranges": [50, 75, 120], "unit": ""}, |
17 | | - {"label": "Satisfaction", "actual": 4.2, "target": 4.5, "ranges": [3.0, 4.0, 5.0], "unit": "/5"}, |
| 15 | + {"label": "Revenue", "actual": 92, "target": 85, "ranges": [40, 70, 100]}, |
| 16 | + {"label": "Profit Margin", "actual": 38, "target": 45, "ranges": [20, 40, 60]}, |
| 17 | + {"label": "Customer Growth", "actual": 71, "target": 80, "ranges": [30, 60, 100]}, |
| 18 | + {"label": "Satisfaction", "actual": 84, "target": 90, "ranges": [50, 75, 100]}, |
| 19 | + {"label": "On-Time Delivery", "actual": 96, "target": 95, "ranges": [60, 80, 100]}, |
18 | 20 | ] |
19 | 21 |
|
20 | 22 | # Qualitative band colors (grayscale: poor -> satisfactory -> good) |
21 | | -band_colors = ["#d9d9d9", "#bfbfbf", "#a6a6a6"] |
| 23 | +band_colors = ["#e0e0e0", "#c8c8c8", "#b0b0b0"] |
| 24 | +actual_color = "#306998" |
| 25 | +target_color = "#1a1a1a" |
22 | 26 |
|
23 | 27 | # Create plot (4800x2700 px) |
24 | 28 | fig, ax = plt.subplots(figsize=(16, 9)) |
25 | 29 |
|
26 | | -bar_height = 0.4 |
27 | | -spacing = 1.5 |
| 30 | +bar_height = 0.32 |
| 31 | +band_height = bar_height * 2.4 |
| 32 | +spacing = 1.2 |
28 | 33 | y_positions = [i * spacing for i in range(len(metrics))] |
29 | 34 |
|
30 | 35 | for i, metric in enumerate(metrics): |
31 | 36 | y = y_positions[i] |
32 | 37 | ranges = metric["ranges"] |
33 | | - max_range = ranges[-1] |
34 | 38 |
|
35 | | - # Draw qualitative range bands (background bands from low to high) |
| 39 | + # Draw qualitative range bands using FancyBboxPatch for rounded corners |
36 | 40 | band_starts = [0] + ranges[:-1] |
37 | | - band_ends = ranges |
38 | | - |
39 | | - for j, (start, end) in enumerate(zip(band_starts, band_ends, strict=True)): |
| 41 | + for j, (start, end) in enumerate(zip(band_starts, ranges, strict=True)): |
40 | 42 | width = end - start |
41 | | - ax.barh(y, width, left=start, height=bar_height * 2.2, color=band_colors[j], edgecolor="none", zorder=1) |
42 | | - |
43 | | - # Draw actual value bar (the main measure) |
44 | | - ax.barh(y, metric["actual"], height=bar_height, color="#306998", edgecolor="none", zorder=2) |
| 43 | + box = FancyBboxPatch( |
| 44 | + (start, y - band_height / 2), |
| 45 | + width, |
| 46 | + band_height, |
| 47 | + boxstyle="round,pad=0,rounding_size=0.08", |
| 48 | + facecolor=band_colors[j], |
| 49 | + edgecolor="none", |
| 50 | + zorder=1, |
| 51 | + ) |
| 52 | + ax.add_patch(box) |
| 53 | + |
| 54 | + # Draw actual value bar |
| 55 | + actual_bar = FancyBboxPatch( |
| 56 | + (0, y - bar_height / 2), |
| 57 | + metric["actual"], |
| 58 | + bar_height, |
| 59 | + boxstyle="round,pad=0,rounding_size=0.06", |
| 60 | + facecolor=actual_color, |
| 61 | + edgecolor="none", |
| 62 | + zorder=2, |
| 63 | + ) |
| 64 | + ax.add_patch(actual_bar) |
45 | 65 |
|
46 | | - # Draw target marker (vertical line) |
| 66 | + # Draw target marker (diamond with white outline for visibility) |
47 | 67 | ax.plot( |
48 | | - [metric["target"], metric["target"]], |
49 | | - [y - bar_height * 0.7, y + bar_height * 0.7], |
50 | | - color="#1a1a1a", |
51 | | - linewidth=4, |
52 | | - solid_capstyle="butt", |
| 68 | + metric["target"], |
| 69 | + y, |
| 70 | + marker="D", |
| 71 | + markersize=11, |
| 72 | + color=target_color, |
53 | 73 | zorder=3, |
| 74 | + path_effects=[pe.withStroke(linewidth=3, foreground="white")], |
54 | 75 | ) |
55 | 76 |
|
56 | | - # Add actual value as text label (positioned after the max range for consistency) |
57 | | - label_x = max_range + max_range * 0.03 |
| 77 | + # Actual value label to the right of the max range |
58 | 78 | ax.text( |
59 | | - label_x, |
| 79 | + ranges[-1] + 2, |
60 | 80 | y, |
61 | | - f"{metric['actual']}{metric['unit']}", |
| 81 | + f"{metric['actual']}%", |
62 | 82 | va="center", |
63 | 83 | ha="left", |
64 | 84 | fontsize=16, |
65 | 85 | fontweight="bold", |
66 | | - color="#306998", |
| 86 | + color=actual_color, |
67 | 87 | zorder=4, |
68 | 88 | ) |
69 | 89 |
|
70 | 90 | # Y-axis labels (metric names) |
71 | 91 | ax.set_yticks(y_positions) |
72 | | -ax.set_yticklabels([m["label"] for m in metrics], fontsize=18) |
| 92 | +ax.set_yticklabels([m["label"] for m in metrics], fontsize=18, fontweight="bold") |
73 | 93 |
|
74 | | -# X-axis styling - no label since metrics have different units |
| 94 | +# X-axis label — all metrics share a percentage scale |
| 95 | +ax.set_xlabel("Performance (%)", fontsize=20) |
75 | 96 | ax.tick_params(axis="x", labelsize=16) |
76 | | -ax.tick_params(axis="y", labelsize=18) |
| 97 | +ax.tick_params(axis="y", length=0) |
77 | 98 |
|
78 | 99 | # Title |
79 | | -ax.set_title("bullet-basic · matplotlib · pyplots.ai", fontsize=24, pad=20) |
| 100 | +ax.set_title("bullet-basic \u00b7 matplotlib \u00b7 pyplots.ai", fontsize=24, fontweight="medium", pad=20) |
80 | 101 |
|
81 | 102 | # Grid on x-axis only, subtle |
82 | | -ax.xaxis.grid(True, alpha=0.3, linestyle="--", zorder=0) |
| 103 | +ax.xaxis.grid(True, alpha=0.2, linewidth=0.8, linestyle="--", zorder=0) |
83 | 104 | ax.set_axisbelow(True) |
84 | 105 |
|
85 | 106 | # Remove spines for cleaner look |
86 | | -ax.spines["top"].set_visible(False) |
87 | | -ax.spines["right"].set_visible(False) |
88 | | -ax.spines["left"].set_visible(False) |
89 | | - |
90 | | -# Set x-axis to start at 0 |
91 | | -ax.set_xlim(left=0) |
| 107 | +for spine in ["top", "right", "left"]: |
| 108 | + ax.spines[spine].set_visible(False) |
92 | 109 |
|
93 | | -# Adjust y-axis limits for padding |
94 | | -ax.set_ylim(-spacing * 0.4, y_positions[-1] + spacing * 0.4) |
| 110 | +# Set axis limits |
| 111 | +ax.set_xlim(left=0, right=112) |
| 112 | +ax.set_ylim(-spacing * 0.5, y_positions[-1] + spacing * 0.5) |
95 | 113 |
|
96 | 114 | # Invert y-axis so first metric is at top |
97 | 115 | ax.invert_yaxis() |
98 | 116 |
|
99 | | -# Add legend |
| 117 | +# Legend |
100 | 118 | legend_elements = [ |
101 | | - Patch(facecolor="#306998", edgecolor="none", label="Actual"), |
102 | | - Line2D([0], [0], color="#1a1a1a", linewidth=4, label="Target"), |
103 | | - Patch(facecolor="#a6a6a6", edgecolor="none", label="Good"), |
104 | | - Patch(facecolor="#bfbfbf", edgecolor="none", label="Satisfactory"), |
105 | | - Patch(facecolor="#d9d9d9", edgecolor="none", label="Poor"), |
| 119 | + Patch(facecolor=actual_color, edgecolor="none", label="Actual"), |
| 120 | + Line2D([0], [0], marker="D", color="w", markerfacecolor=target_color, markersize=8, label="Target"), |
| 121 | + Patch(facecolor=band_colors[2], edgecolor="none", label="Good"), |
| 122 | + Patch(facecolor=band_colors[1], edgecolor="none", label="Satisfactory"), |
| 123 | + Patch(facecolor=band_colors[0], edgecolor="none", label="Poor"), |
106 | 124 | ] |
107 | | -ax.legend(handles=legend_elements, loc="lower right", fontsize=14, framealpha=0.9) |
| 125 | +ax.legend(handles=legend_elements, loc="upper center", bbox_to_anchor=(0.5, -0.08), ncol=5, fontsize=14, frameon=False) |
108 | 126 |
|
109 | 127 | plt.tight_layout() |
110 | 128 | plt.savefig("plot.png", dpi=300, bbox_inches="tight") |
0 commit comments