Basic Dumbbell Chart — Bokeh

A dumbbell chart (also called a connected dot plot or Cleveland dot plot) compares two values for each category by displaying two dots connected by a line. It effectively visualizes differences, changes, or ranges between two data points such as before/after comparisons, gaps, or min/max values. The connected dots make it easy to see both the magnitude and direction of change.

Basic Dumbbell Chart rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
dumbbell-basic: Basic Dumbbell Chart
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


# 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
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
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"  # Imprint palette position 1 — "Before" dots
ACCENT = "#C475FD"  # Imprint palette position 2 — "After" dots

# Data — Employee satisfaction scores before and after policy changes
categories = [
    "Engineering",
    "Marketing",
    "Sales",
    "Customer Support",
    "Human Resources",
    "Finance",
    "Operations",
    "Research & Development",
]
start_values = [62, 58, 71, 55, 68, 78, 60, 65]
end_values = [78, 74, 82, 70, 85, 72, 73, 88]

# Sort ascending by change so the largest improvements sit at the top
deltas = [e - s for s, e in zip(start_values, end_values, strict=True)]
ordered = sorted(zip(categories, start_values, end_values, deltas, strict=True), key=lambda d: d[3])
categories = [d[0] for d in ordered]
start_values = [d[1] for d in ordered]
end_values = [d[2] for d in ordered]
deltas = [d[3] for d in ordered]

# Plot
TITLE = "dumbbell-basic · python · bokeh · anyplot.ai"
p = figure(
    width=3200,
    height=1800,
    y_range=categories,
    x_range=(45, 95),
    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 — 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],
        "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="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 — 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=28,
    fill_color=BRAND,
    line_color=PAGE_BG,
    line_width=3,
    legend_label="Before policy changes",
)

# "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=28,
    fill_color=ACCENT,
    line_color=PAGE_BG,
    line_width=3,
    legend_label="After policy changes",
)

# 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"), ("Δ Change", "@delta")],
    )
)

# 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 = "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 — 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 = None
p.xaxis.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

# Grid — subtle vertical guides only
p.xgrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.10
p.ygrid.grid_line_color = None

# 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 = "34pt"
p.legend.spacing = 10
p.legend.padding = 18
p.legend.margin = 24

# 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()

Part of Basic Dumbbell Chart on anyplot.ai.

Other implementations