|
1 | 1 | """ anyplot.ai |
2 | 2 | dumbbell-basic: Basic Dumbbell Chart |
3 | | -Library: bokeh 3.9.0 | Python 3.14.4 |
4 | | -Quality: 88/100 | Updated: 2026-04-26 |
| 3 | +Library: bokeh 3.9.1 | Python 3.13.14 |
| 4 | +Quality: 88/100 | Updated: 2026-06-30 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
| 8 | +import sys |
| 9 | +import time |
| 10 | +from pathlib import Path |
8 | 11 |
|
9 | | -from bokeh.io import export_png, output_file, save |
10 | | -from bokeh.models import ColumnDataSource, HoverTool |
| 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] |
| 17 | + |
| 18 | +from bokeh.io import output_file, save |
| 19 | +from bokeh.models import ColumnDataSource, HoverTool, LabelSet |
11 | 20 | from bokeh.plotting import figure |
| 21 | +from selenium import webdriver |
| 22 | +from selenium.webdriver.chrome.options import Options |
12 | 23 |
|
13 | 24 |
|
14 | 25 | # Theme tokens |
|
17 | 28 | ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
18 | 29 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
19 | 30 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
20 | | -BRAND = "#009E73" # Okabe-Ito 1 — "Before" |
21 | | -ACCENT = "#C475FD" # Okabe-Ito 2 — "After" |
| 31 | +BRAND = "#009E73" # Imprint palette position 1 — "Before" dots |
| 32 | +ACCENT = "#C475FD" # Imprint palette position 2 — "After" dots |
22 | 33 |
|
23 | 34 | # Data — Employee satisfaction scores before and after policy changes |
24 | | -# (one department regressed, the rest improved by varying amounts) |
25 | 35 | categories = [ |
26 | 36 | "Engineering", |
27 | 37 | "Marketing", |
|
44 | 54 | deltas = [d[3] for d in ordered] |
45 | 55 |
|
46 | 56 | # Plot |
| 57 | +TITLE = "dumbbell-basic · python · bokeh · anyplot.ai" |
47 | 58 | p = figure( |
48 | | - width=4800, |
49 | | - height=2700, |
| 59 | + width=3200, |
| 60 | + height=1800, |
50 | 61 | y_range=categories, |
51 | 62 | x_range=(45, 95), |
52 | | - title="Employee Satisfaction · dumbbell-basic · bokeh · anyplot.ai", |
| 63 | + title=TITLE, |
53 | 64 | x_axis_label="Satisfaction Score", |
54 | 65 | y_axis_label="Department", |
55 | 66 | background_fill_color=PAGE_BG, |
56 | 67 | border_fill_color=PAGE_BG, |
57 | 68 | toolbar_location=None, |
| 69 | + min_border_bottom=160, |
| 70 | + min_border_left=280, |
| 71 | + min_border_top=110, |
| 72 | + min_border_right=60, |
58 | 73 | ) |
59 | 74 |
|
60 | | -# Connecting segments — thin, subtle, behind the dots |
| 75 | +# Connecting segments — color-coded by direction: green = improvement, red = regression |
61 | 76 | seg_source = ColumnDataSource( |
62 | | - data={"y": categories, "x_start": start_values, "x_end": end_values, "delta": [f"{d:+d}" for d in deltas]} |
| 77 | + data={ |
| 78 | + "y": categories, |
| 79 | + "x_start": start_values, |
| 80 | + "x_end": end_values, |
| 81 | + "delta": [f"{d:+d}" for d in deltas], |
| 82 | + "seg_color": [BRAND if d > 0 else "#AE3030" for d in deltas], |
| 83 | + } |
63 | 84 | ) |
64 | 85 | p.segment( |
65 | | - x0="x_start", x1="x_end", y0="y", y1="y", source=seg_source, line_color=INK_SOFT, line_alpha=0.45, line_width=4 |
| 86 | + x0="x_start", x1="x_end", y0="y", y1="y", source=seg_source, line_color="seg_color", line_alpha=0.55, line_width=6 |
| 87 | +) |
| 88 | + |
| 89 | +# Delta labels on connecting segments — makes improvement/regression story explicit |
| 90 | +label_source = ColumnDataSource( |
| 91 | + data={ |
| 92 | + "x": [(s + e) / 2 for s, e in zip(start_values, end_values, strict=True)], |
| 93 | + "y": categories, |
| 94 | + "text": [f"{d:+d}" for d in deltas], |
| 95 | + } |
| 96 | +) |
| 97 | +p.add_layout( |
| 98 | + LabelSet( |
| 99 | + x="x", |
| 100 | + y="y", |
| 101 | + text="text", |
| 102 | + source=label_source, |
| 103 | + text_align="center", |
| 104 | + text_baseline="bottom", |
| 105 | + text_font_size="26pt", |
| 106 | + text_color=INK_SOFT, |
| 107 | + y_offset=24, |
| 108 | + ) |
66 | 109 | ) |
67 | 110 |
|
68 | | -# "Before" dots — Okabe-Ito brand green |
69 | | -before_source = ColumnDataSource(data={"x": start_values, "y": categories, "phase": ["Before"] * len(categories)}) |
| 111 | +# "Before" dots — Imprint palette position 1 (brand green) |
| 112 | +before_source = ColumnDataSource( |
| 113 | + data={ |
| 114 | + "x": start_values, |
| 115 | + "y": categories, |
| 116 | + "phase": ["Before"] * len(categories), |
| 117 | + "after": end_values, |
| 118 | + "delta": [f"{d:+d} pts" for d in deltas], |
| 119 | + } |
| 120 | +) |
70 | 121 | before_glyph = p.scatter( |
71 | 122 | x="x", |
72 | 123 | y="y", |
73 | 124 | source=before_source, |
74 | | - size=34, |
| 125 | + size=28, |
75 | 126 | fill_color=BRAND, |
76 | 127 | line_color=PAGE_BG, |
77 | | - line_width=2, |
| 128 | + line_width=3, |
78 | 129 | legend_label="Before policy changes", |
79 | 130 | ) |
80 | 131 |
|
81 | | -# "After" dots — Okabe-Ito vermillion |
82 | | -after_source = ColumnDataSource(data={"x": end_values, "y": categories, "phase": ["After"] * len(categories)}) |
| 132 | +# "After" dots — Imprint palette position 2 (lavender) |
| 133 | +after_source = ColumnDataSource( |
| 134 | + data={ |
| 135 | + "x": end_values, |
| 136 | + "y": categories, |
| 137 | + "phase": ["After"] * len(categories), |
| 138 | + "before": start_values, |
| 139 | + "delta": [f"{d:+d} pts" for d in deltas], |
| 140 | + } |
| 141 | +) |
83 | 142 | after_glyph = p.scatter( |
84 | 143 | x="x", |
85 | 144 | y="y", |
86 | 145 | source=after_source, |
87 | | - size=34, |
| 146 | + size=28, |
88 | 147 | fill_color=ACCENT, |
89 | 148 | line_color=PAGE_BG, |
90 | | - line_width=2, |
| 149 | + line_width=3, |
91 | 150 | legend_label="After policy changes", |
92 | 151 | ) |
93 | 152 |
|
94 | | -# Hover tooltip (HTML interactivity) |
| 153 | +# Hover tooltip — shows department, phase, score, and delta change together |
95 | 154 | p.add_tools( |
96 | 155 | HoverTool( |
97 | | - renderers=[before_glyph, after_glyph], tooltips=[("Department", "@y"), ("Phase", "@phase"), ("Score", "@x")] |
| 156 | + renderers=[before_glyph, after_glyph], |
| 157 | + tooltips=[("Department", "@y"), ("Phase", "@phase"), ("Score", "@x"), ("Δ Change", "@delta")], |
98 | 158 | ) |
99 | 159 | ) |
100 | 160 |
|
101 | | -# Typography |
102 | | -p.title.text_font_size = "36pt" |
| 161 | +# Typography — canonical sizes for 3200×1800 per bokeh.md |
| 162 | +p.title.text_font_size = "50pt" |
103 | 163 | p.title.text_color = INK |
104 | 164 | p.title.text_font_style = "normal" |
105 | 165 | p.title.align = "center" |
106 | 166 |
|
107 | | -p.xaxis.axis_label_text_font_size = "24pt" |
108 | | -p.yaxis.axis_label_text_font_size = "24pt" |
109 | | -p.xaxis.major_label_text_font_size = "20pt" |
110 | | -p.yaxis.major_label_text_font_size = "20pt" |
| 167 | +p.xaxis.axis_label_text_font_size = "42pt" |
| 168 | +p.yaxis.axis_label_text_font_size = "36pt" |
| 169 | +p.xaxis.major_label_text_font_size = "34pt" |
| 170 | +p.yaxis.major_label_text_font_size = "34pt" |
111 | 171 | p.xaxis.axis_label_text_color = INK |
112 | 172 | p.yaxis.axis_label_text_color = INK |
113 | 173 | p.xaxis.major_label_text_color = INK_SOFT |
114 | 174 | p.yaxis.major_label_text_color = INK_SOFT |
115 | 175 | p.xaxis.axis_label_standoff = 18 |
116 | 176 | p.yaxis.axis_label_standoff = 18 |
117 | 177 |
|
118 | | -# Spines and ticks — keep an L-shape, suppress chart outline |
| 178 | +# Spines — keep x-axis, remove y-axis for a cleaner look |
119 | 179 | p.outline_line_color = None |
120 | 180 | p.xaxis.axis_line_color = INK_SOFT |
121 | | -p.yaxis.axis_line_color = INK_SOFT |
| 181 | +p.yaxis.axis_line_color = None |
122 | 182 | p.xaxis.major_tick_line_color = INK_SOFT |
123 | | -p.yaxis.major_tick_line_color = INK_SOFT |
| 183 | +p.yaxis.major_tick_line_color = None |
124 | 184 | p.xaxis.minor_tick_line_color = None |
125 | 185 | p.yaxis.minor_tick_line_color = None |
126 | 186 |
|
|
129 | 189 | p.xgrid.grid_line_alpha = 0.10 |
130 | 190 | p.ygrid.grid_line_color = None |
131 | 191 |
|
132 | | -# Legend — placed inside top-left so it never collides with the data range |
133 | | -p.legend.location = "top_left" |
| 192 | +# Legend |
| 193 | +p.legend.location = "bottom_left" |
134 | 194 | p.legend.background_fill_color = ELEVATED_BG |
135 | 195 | p.legend.background_fill_alpha = 0.95 |
136 | 196 | p.legend.border_line_color = INK_SOFT |
137 | 197 | p.legend.border_line_alpha = 0.4 |
138 | 198 | p.legend.label_text_color = INK_SOFT |
139 | | -p.legend.label_text_font_size = "20pt" |
| 199 | +p.legend.label_text_font_size = "34pt" |
140 | 200 | p.legend.spacing = 10 |
141 | 201 | p.legend.padding = 18 |
142 | 202 | p.legend.margin = 24 |
143 | 203 |
|
144 | | -# Save |
145 | | -export_png(p, filename=f"plot-{THEME}.png") |
146 | | -output_file(f"plot-{THEME}.html", title="Employee Satisfaction · dumbbell-basic · bokeh · anyplot.ai") |
| 204 | +# Save interactive HTML (required catalog artifact) |
| 205 | +html_path = Path(f"plot-{THEME}.html") |
| 206 | +output_file(str(html_path), title=TITLE) |
147 | 207 | save(p) |
| 208 | + |
| 209 | +# Inject body background CSS to prevent thin border artifact in headless-Chrome screenshot |
| 210 | +html_content = html_path.read_text() |
| 211 | +body_style = f"<style>body{{margin:0;padding:0;background:{PAGE_BG};}}</style>" |
| 212 | +html_content = html_content.replace("</head>", f"{body_style}\n</head>", 1) |
| 213 | +html_path.write_text(html_content) |
| 214 | + |
| 215 | +# Screenshot via headless Chrome — use CDP to set exact viewport to match figure dimensions |
| 216 | +W, H = 3200, 1800 |
| 217 | +opts = Options() |
| 218 | +for arg in ( |
| 219 | + "--headless=new", |
| 220 | + "--no-sandbox", |
| 221 | + "--disable-dev-shm-usage", |
| 222 | + "--disable-gpu", |
| 223 | + f"--window-size={W},{H}", |
| 224 | + "--hide-scrollbars", |
| 225 | +): |
| 226 | + opts.add_argument(arg) |
| 227 | +driver = webdriver.Chrome(options=opts) |
| 228 | +driver.execute_cdp_cmd( |
| 229 | + "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False} |
| 230 | +) |
| 231 | +driver.get(f"file://{html_path.resolve()}") |
| 232 | +time.sleep(3) |
| 233 | +driver.save_screenshot(f"plot-{THEME}.png") |
| 234 | +driver.quit() |
0 commit comments