|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | bullet-basic: Basic Bullet Chart |
3 | | -Library: bokeh 3.8.1 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-23 |
| 3 | +Library: bokeh 3.8.2 | Python 3.14.3 |
| 4 | +Quality: /100 | Updated: 2026-02-22 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | from bokeh.io import export_png, output_file, save |
8 | | -from bokeh.models import Label |
| 8 | +from bokeh.models import Label, Range1d |
9 | 9 | from bokeh.plotting import figure |
10 | 10 |
|
11 | 11 |
|
12 | 12 | # Data - Sales performance metrics with targets |
13 | 13 | metrics = [ |
14 | | - {"label": "Revenue", "actual": 275, "target": 250, "ranges": [150, 225, 300]}, |
15 | | - {"label": "Profit", "actual": 85, "target": 100, "ranges": [50, 75, 100]}, |
16 | | - {"label": "Orders", "actual": 320, "target": 350, "ranges": [200, 300, 400]}, |
17 | | - {"label": "Customers", "actual": 1450, "target": 1200, "ranges": [800, 1100, 1500]}, |
18 | | - {"label": "Satisfaction", "actual": 4.2, "target": 4.5, "ranges": [3.0, 4.0, 5.0]}, |
| 14 | + {"label": "Revenue", "unit": "$K", "actual": 275, "target": 250, "ranges": [150, 225, 300]}, |
| 15 | + {"label": "Profit", "unit": "$K", "actual": 85, "target": 100, "ranges": [50, 75, 100]}, |
| 16 | + {"label": "Orders", "unit": "", "actual": 320, "target": 350, "ranges": [200, 300, 400]}, |
| 17 | + {"label": "Customers", "unit": "", "actual": 1450, "target": 1200, "ranges": [800, 1100, 1500]}, |
| 18 | + {"label": "Satisfaction", "unit": "/5", "actual": 4.2, "target": 4.5, "ranges": [3.0, 4.0, 5.0]}, |
19 | 19 | ] |
20 | 20 |
|
21 | 21 | # Configuration |
22 | 22 | num_metrics = len(metrics) |
23 | | -bar_spacing = 1.5 # Space between each bullet row |
24 | | -bar_height = 0.8 # Maximum height of range bars |
| 23 | +bar_spacing = 1.5 |
| 24 | +bar_height = 0.8 |
25 | 25 |
|
26 | | -# Qualitative range colors (grayscale: poor, satisfactory, good - light to dark) |
27 | | -range_colors = ["#dddddd", "#aaaaaa", "#777777"] |
| 26 | +# Qualitative range colors: lightest (widest, good) to darkest (narrowest, poor) |
| 27 | +range_colors = ["#d4d4d4", "#a8a8a8", "#737373"] |
28 | 28 |
|
29 | 29 | # Create figure |
30 | 30 | p = figure( |
31 | 31 | width=4800, |
32 | 32 | height=2700, |
33 | | - x_range=(0, 110), |
34 | | - y_range=(-0.5, num_metrics * bar_spacing), |
| 33 | + x_range=Range1d(-38, 118), |
| 34 | + y_range=Range1d(-0.8, num_metrics * bar_spacing + 0.2), |
35 | 35 | title="bullet-basic · bokeh · pyplots.ai", |
36 | | - x_axis_label="% of Target Range", |
| 36 | + x_axis_label="% of Maximum Range", |
37 | 37 | toolbar_location=None, |
38 | 38 | ) |
39 | 39 |
|
40 | 40 | # Remove y-axis ticks and gridlines |
41 | 41 | p.yaxis.visible = False |
42 | 42 | p.ygrid.grid_line_color = None |
43 | | -p.xgrid.grid_line_alpha = 0.3 |
44 | | -p.xgrid.grid_line_dash = "dashed" |
| 43 | +p.xgrid.grid_line_alpha = 0.15 |
| 44 | +p.xgrid.grid_line_dash = [6, 4] |
45 | 45 |
|
46 | 46 | # Draw bullets for each metric |
47 | 47 | for i, metric in enumerate(metrics): |
48 | | - y_pos = (num_metrics - 1 - i) * bar_spacing # Reverse so first is at top |
| 48 | + y_pos = (num_metrics - 1 - i) * bar_spacing |
49 | 49 | actual = metric["actual"] |
50 | 50 | target = metric["target"] |
51 | 51 | ranges = metric["ranges"] |
|
56 | 56 | norm_target = (target / max_range) * 100 |
57 | 57 | norm_ranges = [(r / max_range) * 100 for r in ranges] |
58 | 58 |
|
59 | | - # Draw qualitative ranges (background bands) - from outer to inner |
| 59 | + # Draw qualitative ranges (background bands) - widest first (lightest = good) |
60 | 60 | for j in range(len(norm_ranges) - 1, -1, -1): |
61 | 61 | range_width = norm_ranges[j] |
62 | | - height_factor = 1 - j * 0.25 # Decrease height for inner ranges |
| 62 | + height_factor = 1 - j * 0.2 |
63 | 63 | h = bar_height * height_factor |
64 | 64 | p.rect(x=range_width / 2, y=y_pos, width=range_width, height=h, color=range_colors[j], line_color=None) |
65 | 65 |
|
66 | | - # Draw actual value bar (primary measure) |
67 | | - actual_bar_height = bar_height * 0.35 |
| 66 | + # Draw actual value bar |
| 67 | + actual_bar_height = bar_height * 0.3 |
68 | 68 | p.rect(x=norm_actual / 2, y=y_pos, width=norm_actual, height=actual_bar_height, color="#306998", line_color=None) |
69 | 69 |
|
70 | | - # Draw target marker (thin black vertical line) |
71 | | - target_marker_height = bar_height * 0.6 |
72 | | - p.rect(x=norm_target, y=y_pos, width=0.8, height=target_marker_height, color="#1a1a1a", line_color=None) |
| 70 | + # Draw target marker (thin vertical line) |
| 71 | + target_marker_height = bar_height * 0.55 |
| 72 | + p.rect(x=norm_target, y=y_pos, width=0.7, height=target_marker_height, color="#1a1a1a", line_color=None) |
73 | 73 |
|
74 | | - # Add metric label on the left |
| 74 | + # Add metric label with unit |
| 75 | + unit_text = f" ({metric['unit']})" if metric["unit"] else "" |
75 | 76 | label = Label( |
76 | 77 | x=-2, |
77 | 78 | y=y_pos, |
78 | | - text=metric["label"], |
| 79 | + text=f"{metric['label']}{unit_text}", |
79 | 80 | text_font_size="28pt", |
80 | 81 | text_color="#333333", |
81 | 82 | text_align="right", |
|
84 | 85 | ) |
85 | 86 | p.add_layout(label) |
86 | 87 |
|
87 | | - # Add actual value text label on the right |
| 88 | + # Add actual value as text |
| 89 | + value_text = str(int(actual)) if actual == int(actual) else str(actual) |
88 | 90 | value_label = Label( |
89 | | - x=norm_actual + 3, |
| 91 | + x=norm_actual + 2, |
90 | 92 | y=y_pos, |
91 | | - text=str(metric["actual"]), |
92 | | - text_font_size="24pt", |
| 93 | + text=value_text, |
| 94 | + text_font_size="22pt", |
93 | 95 | text_color="#306998", |
94 | 96 | text_align="left", |
95 | 97 | text_baseline="middle", |
96 | 98 | text_font_style="bold", |
97 | 99 | ) |
98 | 100 | p.add_layout(value_label) |
99 | 101 |
|
100 | | -# Extend x_range to accommodate labels (but clip axis display) |
101 | | -p.x_range.start = -35 |
102 | | -p.x_range.end = 115 |
| 102 | +# Legend - positioned below the chart |
| 103 | +legend_y = -0.5 |
| 104 | +legend_start_x = 10 |
| 105 | +legend_spacing = 22 |
| 106 | +range_labels = ["Poor", "Satisfactory", "Good"] |
| 107 | +box_w = 4 |
| 108 | +box_h = 0.2 |
| 109 | + |
| 110 | +for k, (color, lbl) in enumerate(zip(range_colors[::-1], range_labels, strict=True)): |
| 111 | + lx = legend_start_x + k * legend_spacing |
| 112 | + p.rect(x=lx, y=legend_y, width=box_w, height=box_h, color=color, line_color="#999999", line_width=1) |
| 113 | + p.add_layout( |
| 114 | + Label( |
| 115 | + x=lx + box_w / 2 + 1, |
| 116 | + y=legend_y, |
| 117 | + text=lbl, |
| 118 | + text_font_size="20pt", |
| 119 | + text_color="#555555", |
| 120 | + text_align="left", |
| 121 | + text_baseline="middle", |
| 122 | + ) |
| 123 | + ) |
| 124 | + |
| 125 | +# Target marker legend entry |
| 126 | +target_lx = legend_start_x + len(range_labels) * legend_spacing |
| 127 | +p.rect(x=target_lx, y=legend_y, width=1.0, height=box_h, color="#1a1a1a", line_color=None) |
| 128 | +p.add_layout( |
| 129 | + Label( |
| 130 | + x=target_lx + box_w / 2 + 1, |
| 131 | + y=legend_y, |
| 132 | + text="Target", |
| 133 | + text_font_size="20pt", |
| 134 | + text_color="#555555", |
| 135 | + text_align="left", |
| 136 | + text_baseline="middle", |
| 137 | + ) |
| 138 | +) |
103 | 139 |
|
104 | | -# Styling - scaled for 4800x2700 canvas |
105 | | -p.title.text_font_size = "42pt" |
| 140 | +# Style - scaled for 4800x2700 canvas |
| 141 | +p.title.text_font_size = "36pt" |
106 | 142 | p.title.text_color = "#333333" |
107 | 143 | p.title.align = "center" |
108 | | -p.xaxis.axis_label_text_font_size = "28pt" |
109 | | -p.xaxis.major_label_text_font_size = "22pt" |
110 | | - |
111 | | -# Only show positive tick marks on x-axis |
| 144 | +p.xaxis.axis_label_text_font_size = "24pt" |
| 145 | +p.xaxis.major_label_text_font_size = "20pt" |
112 | 146 | p.xaxis.ticker = [0, 20, 40, 60, 80, 100] |
113 | | - |
114 | | -# Axis styling |
115 | 147 | p.xaxis.axis_line_color = "#666666" |
116 | 148 | p.outline_line_color = None |
117 | 149 |
|
118 | 150 | # Save as PNG |
119 | 151 | export_png(p, filename="plot.png") |
120 | 152 |
|
121 | | -# Save as HTML for interactivity |
| 153 | +# Save as HTML |
122 | 154 | output_file("plot.html") |
123 | 155 | save(p) |
0 commit comments