Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 125 additions & 38 deletions plots/dumbbell-basic/implementations/python/bokeh.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
""" anyplot.ai
dumbbell-basic: Basic Dumbbell Chart
Library: bokeh 3.9.0 | Python 3.14.4
Quality: 88/100 | Updated: 2026-04-26
Library: bokeh 3.9.1 | Python 3.13.14
Quality: 88/100 | Updated: 2026-06-30
"""

import os
import sys
import time
from pathlib import Path

from bokeh.io import export_png, output_file, save
from bokeh.models import ColumnDataSource, HoverTool

# Remove script's own directory from sys.path to prevent self-shadowing
# (this file is named bokeh.py; without this, `import bokeh` would find itself)
_here = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p or ".") != _here]

from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, LabelSet
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens
Expand All @@ -17,11 +28,10 @@
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
BRAND = "#009E73" # Okabe-Ito 1 — "Before"
ACCENT = "#C475FD" # Okabe-Ito 2 — "After"
BRAND = "#009E73" # Imprint palette position 1 — "Before" dots
ACCENT = "#C475FD" # Imprint palette position 2 — "After" dots

# Data — Employee satisfaction scores before and after policy changes
# (one department regressed, the rest improved by varying amounts)
categories = [
"Engineering",
"Marketing",
Expand All @@ -44,83 +54,133 @@
deltas = [d[3] for d in ordered]

# Plot
TITLE = "dumbbell-basic · python · bokeh · anyplot.ai"
p = figure(
width=4800,
height=2700,
width=3200,
height=1800,
y_range=categories,
x_range=(45, 95),
title="Employee Satisfaction · dumbbell-basic · bokeh · anyplot.ai",
title=TITLE,
x_axis_label="Satisfaction Score",
y_axis_label="Department",
background_fill_color=PAGE_BG,
border_fill_color=PAGE_BG,
toolbar_location=None,
min_border_bottom=160,
min_border_left=280,
min_border_top=110,
min_border_right=60,
)

# Connecting segments — thin, subtle, behind the dots
# Connecting segments — color-coded by direction: green = improvement, red = regression
seg_source = ColumnDataSource(
data={"y": categories, "x_start": start_values, "x_end": end_values, "delta": [f"{d:+d}" for d in deltas]}
data={
"y": categories,
"x_start": start_values,
"x_end": end_values,
"delta": [f"{d:+d}" for d in deltas],
"seg_color": [BRAND if d > 0 else "#AE3030" for d in deltas],
}
)
p.segment(
x0="x_start", x1="x_end", y0="y", y1="y", source=seg_source, line_color=INK_SOFT, line_alpha=0.45, line_width=4
x0="x_start", x1="x_end", y0="y", y1="y", source=seg_source, line_color="seg_color", line_alpha=0.55, line_width=6
)

# Delta labels on connecting segments — makes improvement/regression story explicit
label_source = ColumnDataSource(
data={
"x": [(s + e) / 2 for s, e in zip(start_values, end_values, strict=True)],
"y": categories,
"text": [f"{d:+d}" for d in deltas],
}
)
p.add_layout(
LabelSet(
x="x",
y="y",
text="text",
source=label_source,
text_align="center",
text_baseline="bottom",
text_font_size="26pt",
text_color=INK_SOFT,
y_offset=24,
)
)

# "Before" dots — Okabe-Ito brand green
before_source = ColumnDataSource(data={"x": start_values, "y": categories, "phase": ["Before"] * len(categories)})
# "Before" dots — Imprint palette position 1 (brand green)
before_source = ColumnDataSource(
data={
"x": start_values,
"y": categories,
"phase": ["Before"] * len(categories),
"after": end_values,
"delta": [f"{d:+d} pts" for d in deltas],
}
)
before_glyph = p.scatter(
x="x",
y="y",
source=before_source,
size=34,
size=28,
fill_color=BRAND,
line_color=PAGE_BG,
line_width=2,
line_width=3,
legend_label="Before policy changes",
)

# "After" dots — Okabe-Ito vermillion
after_source = ColumnDataSource(data={"x": end_values, "y": categories, "phase": ["After"] * len(categories)})
# "After" dots — Imprint palette position 2 (lavender)
after_source = ColumnDataSource(
data={
"x": end_values,
"y": categories,
"phase": ["After"] * len(categories),
"before": start_values,
"delta": [f"{d:+d} pts" for d in deltas],
}
)
after_glyph = p.scatter(
x="x",
y="y",
source=after_source,
size=34,
size=28,
fill_color=ACCENT,
line_color=PAGE_BG,
line_width=2,
line_width=3,
legend_label="After policy changes",
)

# Hover tooltip (HTML interactivity)
# Hover tooltip — shows department, phase, score, and delta change together
p.add_tools(
HoverTool(
renderers=[before_glyph, after_glyph], tooltips=[("Department", "@y"), ("Phase", "@phase"), ("Score", "@x")]
renderers=[before_glyph, after_glyph],
tooltips=[("Department", "@y"), ("Phase", "@phase"), ("Score", "@x"), ("Δ Change", "@delta")],
)
)

# Typography
p.title.text_font_size = "36pt"
# Typography — canonical sizes for 3200×1800 per bokeh.md
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.title.text_font_style = "normal"
p.title.align = "center"

p.xaxis.axis_label_text_font_size = "24pt"
p.yaxis.axis_label_text_font_size = "24pt"
p.xaxis.major_label_text_font_size = "20pt"
p.yaxis.major_label_text_font_size = "20pt"
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "36pt"
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.xaxis.axis_label_standoff = 18
p.yaxis.axis_label_standoff = 18

# Spines and ticks — keep an L-shape, suppress chart outline
# Spines — keep x-axis, remove y-axis for a cleaner look
p.outline_line_color = None
p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = None
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = None
p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

Expand All @@ -129,19 +189,46 @@
p.xgrid.grid_line_alpha = 0.10
p.ygrid.grid_line_color = None

# Legend — placed inside top-left so it never collides with the data range
p.legend.location = "top_left"
# Legend
p.legend.location = "bottom_left"
p.legend.background_fill_color = ELEVATED_BG
p.legend.background_fill_alpha = 0.95
p.legend.border_line_color = INK_SOFT
p.legend.border_line_alpha = 0.4
p.legend.label_text_color = INK_SOFT
p.legend.label_text_font_size = "20pt"
p.legend.label_text_font_size = "34pt"
p.legend.spacing = 10
p.legend.padding = 18
p.legend.margin = 24

# Save
export_png(p, filename=f"plot-{THEME}.png")
output_file(f"plot-{THEME}.html", title="Employee Satisfaction · dumbbell-basic · bokeh · anyplot.ai")
# Save interactive HTML (required catalog artifact)
html_path = Path(f"plot-{THEME}.html")
output_file(str(html_path), title=TITLE)
save(p)

# Inject body background CSS to prevent thin border artifact in headless-Chrome screenshot
html_content = html_path.read_text()
body_style = f"<style>body{{margin:0;padding:0;background:{PAGE_BG};}}</style>"
html_content = html_content.replace("</head>", f"{body_style}\n</head>", 1)
html_path.write_text(html_content)

# Screenshot via headless Chrome — use CDP to set exact viewport to match figure dimensions
W, H = 3200, 1800
opts = Options()
for arg in (
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
f"--window-size={W},{H}",
"--hide-scrollbars",
):
opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.execute_cdp_cmd(
"Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{html_path.resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()
Loading
Loading