|
1 | 1 | """ anyplot.ai |
2 | 2 | gauge-basic: Basic Gauge Chart |
3 | | -Library: bokeh 3.9.0 | Python 3.14.4 |
4 | | -Quality: 87/100 | Updated: 2026-04-25 |
| 3 | +Library: bokeh 3.9.1 | Python 3.13.14 |
| 4 | +Quality: 89/100 | Updated: 2026-06-30 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
| 8 | +import sys |
| 9 | +import time |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | + |
| 13 | +# Remove script's own directory from sys.path to prevent self-shadowing |
| 14 | +# (this file is named bokeh.py; without this, `import bokeh` would find itself) |
| 15 | +_here = os.path.dirname(os.path.abspath(__file__)) |
| 16 | +sys.path = [p for p in sys.path if os.path.abspath(p or ".") != _here] |
8 | 17 |
|
9 | 18 | import numpy as np |
10 | | -from bokeh.io import export_png, output_file, save |
11 | | -from bokeh.models import Label |
| 19 | +from bokeh.io import output_file, save |
| 20 | +from bokeh.models import ColumnDataSource, HoverTool, Label |
12 | 21 | from bokeh.plotting import figure |
| 22 | +from selenium import webdriver |
| 23 | +from selenium.webdriver.chrome.options import Options |
13 | 24 |
|
14 | 25 |
|
15 | 26 | # Theme tokens |
|
19 | 30 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
20 | 31 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
21 | 32 |
|
22 | | -# Okabe-Ito zones: low / mid / high (intuitive + colorblind-safe) |
23 | | -ZONE_LOW = "#AE3030" # imprint red — bad |
24 | | -ZONE_MID = "#DDCC77" # imprint amber — caution |
25 | | -ZONE_HIGH = "#009E73" # imprint green — good |
| 33 | +# Imprint zone colors — semantic convention: red=bad, amber=caution, green=good |
| 34 | +ZONE_LOW = "#AE3030" # Imprint matte red |
| 35 | +ZONE_MID = "#DDCC77" # Imprint amber |
| 36 | +ZONE_HIGH = "#009E73" # Imprint brand green |
26 | 37 |
|
27 | | -# Data |
| 38 | +# Data — CPU utilization |
28 | 39 | value = 72 |
29 | 40 | min_value = 0 |
30 | 41 | max_value = 100 |
|
34 | 45 | center_x, center_y = 0.0, 0.0 |
35 | 46 | outer_radius = 0.95 |
36 | 47 | inner_radius = 0.62 |
| 48 | +arc_mid_radius = (inner_radius + outer_radius) / 2 # label midpoint inside arc |
37 | 49 | needle_length = 0.86 |
38 | | -start_angle = np.pi # left |
| 50 | +start_angle = np.pi # leftmost position (180°) |
39 | 51 |
|
40 | 52 | # Map data values onto the semi-circle (pi → 0 radians) |
41 | 53 | zone_bounds = np.array([min_value] + thresholds + [max_value]) |
|
46 | 58 |
|
47 | 59 | needle_angle = start_angle - (value - min_value) / (max_value - min_value) * np.pi |
48 | 60 |
|
49 | | -# Figure |
| 61 | +# Figure — landscape canvas; toolbar_location=None prevents height bloat in screenshot |
| 62 | +W, H = 3200, 1800 |
50 | 63 | p = figure( |
51 | | - width=4800, |
52 | | - height=2700, |
53 | | - title="gauge-basic · bokeh · anyplot.ai", |
54 | | - x_range=(-1.25, 1.25), |
55 | | - y_range=(-0.45, 1.20), |
| 64 | + width=W, |
| 65 | + height=H, |
| 66 | + title="gauge-basic · python · bokeh · anyplot.ai", |
| 67 | + x_range=(-1.5, 1.5), |
| 68 | + y_range=(-0.58, 1.32), |
56 | 69 | tools="", |
57 | 70 | toolbar_location=None, |
58 | 71 | background_fill_color=PAGE_BG, |
59 | 72 | border_fill_color=PAGE_BG, |
60 | 73 | outline_line_color=None, |
| 74 | + min_border_bottom=80, |
| 75 | + min_border_left=80, |
| 76 | + min_border_top=120, |
| 77 | + min_border_right=80, |
61 | 78 | ) |
62 | 79 | p.axis.visible = False |
63 | 80 | p.grid.visible = False |
| 81 | +p.outline_line_color = None |
64 | 82 |
|
65 | | -p.title.text_font_size = "44pt" |
| 83 | +p.title.text_font_size = "50pt" |
66 | 84 | p.title.text_color = INK |
67 | 85 | p.title.align = "center" |
68 | 86 |
|
69 | | -# Zone arcs via annular_wedge (cleaner than manual polygons) |
| 87 | +# Zone arcs via annular_wedge — ColumnDataSource enables HoverTool |
70 | 88 | zone_colors = [ZONE_LOW, ZONE_MID, ZONE_HIGH] |
71 | | -for i, color in enumerate(zone_colors): |
72 | | - p.annular_wedge( |
73 | | - x=center_x, |
74 | | - y=center_y, |
75 | | - inner_radius=inner_radius, |
76 | | - outer_radius=outer_radius, |
77 | | - start_angle=zone_angles[i + 1], |
78 | | - end_angle=zone_angles[i], |
79 | | - fill_color=color, |
80 | | - line_color=PAGE_BG, |
81 | | - line_width=4, |
| 89 | +zone_names = ["Low", "Caution", "Optimal"] |
| 90 | +zone_ranges = ["0–30", "30–70", "70–100"] |
| 91 | + |
| 92 | +zone_source = ColumnDataSource( |
| 93 | + data={ |
| 94 | + "x": [center_x, center_x, center_x], |
| 95 | + "y": [center_y, center_y, center_y], |
| 96 | + "inner_radius": [inner_radius, inner_radius, inner_radius], |
| 97 | + "outer_radius": [outer_radius, outer_radius, outer_radius], |
| 98 | + "start_angle": [float(zone_angles[1]), float(zone_angles[2]), float(zone_angles[3])], |
| 99 | + "end_angle": [float(zone_angles[0]), float(zone_angles[1]), float(zone_angles[2])], |
| 100 | + "fill_color": zone_colors, |
| 101 | + "zone": zone_names, |
| 102 | + "range": zone_ranges, |
| 103 | + } |
| 104 | +) |
| 105 | + |
| 106 | +zone_renderer = p.annular_wedge( |
| 107 | + x="x", |
| 108 | + y="y", |
| 109 | + inner_radius="inner_radius", |
| 110 | + outer_radius="outer_radius", |
| 111 | + start_angle="start_angle", |
| 112 | + end_angle="end_angle", |
| 113 | + fill_color="fill_color", |
| 114 | + line_color=PAGE_BG, |
| 115 | + line_width=4, |
| 116 | + source=zone_source, |
| 117 | +) |
| 118 | + |
| 119 | +p.add_tools(HoverTool(renderers=[zone_renderer], tooltips=[("Zone", "@zone"), ("Range", "@range")])) |
| 120 | + |
| 121 | +# Zone labels inside each arc segment — contrasting fixed colors (zones are theme-invariant) |
| 122 | +# red/green zones get light text; amber zone gets dark text for contrast |
| 123 | +zone_mid_values = [15.0, 50.0, 85.0] |
| 124 | +zone_label_colors = ["#FFFDF6", "#1A1A17", "#FFFDF6"] |
| 125 | + |
| 126 | +for mid_val, label_text, label_color in zip(zone_mid_values, zone_names, zone_label_colors, strict=True): |
| 127 | + a = start_angle - (mid_val / max_value) * np.pi |
| 128 | + lx = arc_mid_radius * np.cos(a) |
| 129 | + ly = arc_mid_radius * np.sin(a) |
| 130 | + p.add_layout( |
| 131 | + Label( |
| 132 | + x=lx, |
| 133 | + y=ly, |
| 134 | + text=label_text, |
| 135 | + text_font_size="22pt", |
| 136 | + text_color=label_color, |
| 137 | + text_align="center", |
| 138 | + text_baseline="middle", |
| 139 | + angle=float(a - np.pi / 2), # tangent along arc for natural text flow |
| 140 | + ) |
82 | 141 | ) |
83 | 142 |
|
84 | 143 | # Tick marks and labels |
|
94 | 153 |
|
95 | 154 | p.add_layout( |
96 | 155 | Label( |
97 | | - x=center_x + (outer_radius + 0.20) * cos_a, |
98 | | - y=center_y + (outer_radius + 0.20) * sin_a, |
| 156 | + x=center_x + (outer_radius + 0.22) * cos_a, |
| 157 | + y=center_y + (outer_radius + 0.22) * sin_a, |
99 | 158 | text=str(tick_val), |
100 | | - text_font_size="30pt", |
| 159 | + text_font_size="31pt", |
101 | 160 | text_color=INK_SOFT, |
102 | 161 | text_align="center", |
103 | 162 | text_baseline="middle", |
104 | 163 | ) |
105 | 164 | ) |
106 | 165 |
|
107 | | -# Needle (triangle) |
| 166 | +# Needle (triangle pointing to current value) |
108 | 167 | needle_tip_x = center_x + needle_length * np.cos(needle_angle) |
109 | 168 | needle_tip_y = center_y + needle_length * np.sin(needle_angle) |
110 | 169 | half_base = 0.035 |
|
119 | 178 | ) |
120 | 179 |
|
121 | 180 | # Center hub |
122 | | -p.scatter(x=[center_x], y=[center_y], size=70, marker="circle", fill_color=INK, line_color=PAGE_BG, line_width=4) |
| 181 | +p.scatter(x=[center_x], y=[center_y], size=55, marker="circle", fill_color=INK, line_color=PAGE_BG, line_width=4) |
123 | 182 |
|
124 | 183 | # Value display |
125 | 184 | p.add_layout( |
126 | 185 | Label( |
127 | 186 | x=center_x, |
128 | | - y=-0.28, |
| 187 | + y=-0.20, |
129 | 188 | text=str(value), |
130 | | - text_font_size="84pt", |
| 189 | + text_font_size="80pt", |
131 | 190 | text_color=INK, |
132 | 191 | text_align="center", |
133 | 192 | text_baseline="middle", |
134 | 193 | text_font_style="bold", |
135 | 194 | ) |
136 | 195 | ) |
137 | 196 |
|
138 | | -# Save |
139 | | -export_png(p, filename=f"plot-{THEME}.png") |
140 | | -output_file(f"plot-{THEME}.html") |
| 197 | +# Metric label |
| 198 | +p.add_layout( |
| 199 | + Label( |
| 200 | + x=center_x, |
| 201 | + y=-0.44, |
| 202 | + text="CPU Utilization (%)", |
| 203 | + text_font_size="30pt", |
| 204 | + text_color=INK_SOFT, |
| 205 | + text_align="center", |
| 206 | + text_baseline="middle", |
| 207 | + ) |
| 208 | +) |
| 209 | + |
| 210 | +# Save interactive HTML |
| 211 | +html_path = Path(f"plot-{THEME}.html") |
| 212 | +output_file(str(html_path)) |
141 | 213 | save(p) |
| 214 | + |
| 215 | +# Inject body background CSS to prevent thin border artifact in headless-Chrome screenshot |
| 216 | +html_content = html_path.read_text() |
| 217 | +body_style = f"<style>body{{margin:0;padding:0;background:{PAGE_BG};}}</style>" |
| 218 | +html_content = html_content.replace("</head>", f"{body_style}\n</head>", 1) |
| 219 | +html_path.write_text(html_content) |
| 220 | + |
| 221 | +# Screenshot via headless Chrome — use CDP to set exact viewport to match figure dimensions |
| 222 | +opts = Options() |
| 223 | +for arg in ( |
| 224 | + "--headless=new", |
| 225 | + "--no-sandbox", |
| 226 | + "--disable-dev-shm-usage", |
| 227 | + "--disable-gpu", |
| 228 | + f"--window-size={W},{H}", |
| 229 | + "--hide-scrollbars", |
| 230 | +): |
| 231 | + opts.add_argument(arg) |
| 232 | +driver = webdriver.Chrome(options=opts) |
| 233 | +driver.execute_cdp_cmd( |
| 234 | + "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False} |
| 235 | +) |
| 236 | +driver.get(f"file://{html_path.resolve()}") |
| 237 | +time.sleep(3) |
| 238 | +driver.save_screenshot(f"plot-{THEME}.png") |
| 239 | +driver.quit() |
0 commit comments