Grouped Box Plot — Bokeh

A grouped box plot displays multiple box plots side-by-side within each category, enabling comparison of distributions across subgroups. Each group contains boxes representing different subcategories or conditions, making it ideal for multi-factor comparisons and A/B testing scenarios with multiple metrics.

Grouped Box Plot rendered with Bokeh

Renders

Python source (Bokeh)

""" anyplot.ai
box-grouped: Grouped Box Plot
Library: bokeh 3.9.2 | Python 3.13.15
Quality: 91/100 | Updated: 2026-08-18
"""

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import Arrow, ColumnDataSource, FixedTicker, HoverTool, Label, Legend, LegendItem, NormalHead, Range1d
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens (see prompts/default-style-guide.md "Theme-adaptive Chrome")
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"

# Imprint palette — first categorical series is always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3"]

# Data - Employee performance scores across departments by experience level
np.random.seed(42)

categories = ["Sales", "Engineering", "Marketing", "Support"]
subcategories = ["Junior", "Senior", "Lead"]

# Generate performance data with different distributions per group
data = {}
for cat in categories:
    data[cat] = {}
    for i, sub in enumerate(subcategories):
        # Different base means for departments
        base = {"Sales": 70, "Engineering": 75, "Marketing": 68, "Support": 72}[cat]
        # Experience adds to mean
        exp_bonus = i * 8
        # Generate realistic performance scores (50-100 range)
        n_points = 50
        scores = np.random.normal(base + exp_bonus, 10, n_points)
        scores = np.clip(scores, 40, 100)
        # Add some outliers for visual interest
        if cat == "Engineering" and sub == "Lead":
            scores = np.append(scores, [38, 100, 100])  # Add outliers
        if cat == "Sales" and sub == "Junior":
            scores = np.append(scores, [35, 105])  # Add outliers
        data[cat][sub] = scores


# Calculate box plot statistics
def calc_boxplot_stats(values):
    q1 = np.percentile(values, 25)
    q2 = np.percentile(values, 50)  # median
    q3 = np.percentile(values, 75)
    iqr = q3 - q1
    upper_whisker = min(max(values), q3 + 1.5 * iqr)
    lower_whisker = max(min(values), q1 - 1.5 * iqr)
    outliers = values[(values < lower_whisker) | (values > upper_whisker)]
    return {"q1": q1, "q2": q2, "q3": q3, "lower": lower_whisker, "upper": upper_whisker, "outliers": outliers}


# Create figure — width/height are the TOTAL canvas (see prompts/library/bokeh.md "Canvas — hard rule")
p = figure(
    width=3200,
    height=1800,
    # A plain FactorRange's implicit padding isn't wide enough to fit boxes
    # offset from the first/last category (they clip against the frame edge)
    # — use an explicit numeric range with room for the widest box offset.
    x_range=Range1d(start=0.5, end=len(categories) + 0.5),
    y_range=(30, 110),
    title="box-grouped · python · bokeh · anyplot.ai",
    x_axis_label="Department",
    y_axis_label="Performance Score",
    tools="",
    toolbar_location=None,  # bokeh's default toolbar shrinks the saved PNG below the target height
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=110,
    min_border_right=50,
)

# Styling
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.title.align = "center"
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
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_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.xgrid.grid_line_color = INK
p.ygrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.15
p.ygrid.grid_line_alpha = 0.15
p.xgrid.grid_line_dash = "dashed"
p.ygrid.grid_line_dash = "dashed"
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None  # despine-equivalent: no boxed plot border

# Box dimensions
box_width = 0.22
offsets = [-0.28, 0, 0.28]  # Position offsets for subcategories

# Store renderers for legend and hover tooltips
legend_items = []
box_renderers = []

# Track medians and the highest visible point per category — used below to
# build a data-driven "Junior -> Lead" trend annotation (DE-03).
medians = {sub: {} for sub in subcategories}
max_y_per_cat = dict.fromkeys(categories, -np.inf)

# Draw grouped box plots
for sub_idx, sub in enumerate(subcategories):
    color = IMPRINT_PALETTE[sub_idx]
    offset = offsets[sub_idx]

    # Collect data for this subcategory across all categories
    boxes_lower = []
    boxes_upper = []
    boxes_q1 = []
    boxes_q2 = []
    boxes_q3 = []
    x_positions = []
    all_outliers_x = []
    all_outliers_y = []

    for cat_idx, cat in enumerate(categories):
        stats = calc_boxplot_stats(data[cat][sub])
        # bokeh's FactorRange places factors at synthetic coordinates 1, 2, 3, ...
        # (1-indexed), not 0-indexed — offset from (cat_idx + 1), not cat_idx.
        x_pos = (cat_idx + 1) + offset
        x_positions.append(x_pos)

        boxes_lower.append(stats["lower"])
        boxes_upper.append(stats["upper"])
        boxes_q1.append(stats["q1"])
        boxes_q2.append(stats["q2"])
        boxes_q3.append(stats["q3"])

        medians[sub][cat] = stats["q2"]
        cat_top = max(stats["upper"], *stats["outliers"]) if len(stats["outliers"]) else stats["upper"]
        max_y_per_cat[cat] = max(max_y_per_cat[cat], cat_top)

        # Collect outliers
        for outlier in stats["outliers"]:
            all_outliers_x.append(x_pos)
            all_outliers_y.append(outlier)

    # Draw whisker stems (vertical lines from lower to upper)
    for i, _cat in enumerate(categories):
        x_pos = x_positions[i]
        # Lower whisker
        p.segment(
            x0=[x_pos],
            y0=[boxes_lower[i]],
            x1=[x_pos],
            y1=[boxes_q1[i]],
            line_color=INK_SOFT,
            line_width=3,
            line_cap="round",
        )
        # Upper whisker
        p.segment(
            x0=[x_pos],
            y0=[boxes_q3[i]],
            x1=[x_pos],
            y1=[boxes_upper[i]],
            line_color=INK_SOFT,
            line_width=3,
            line_cap="round",
        )
        # Whisker caps
        cap_width = box_width * 0.6
        p.segment(
            x0=[x_pos - cap_width / 2],
            y0=[boxes_lower[i]],
            x1=[x_pos + cap_width / 2],
            y1=[boxes_lower[i]],
            line_color=INK_SOFT,
            line_width=3,
            line_cap="round",
        )
        p.segment(
            x0=[x_pos - cap_width / 2],
            y0=[boxes_upper[i]],
            x1=[x_pos + cap_width / 2],
            y1=[boxes_upper[i]],
            line_color=INK_SOFT,
            line_width=3,
            line_cap="round",
        )

    # Draw boxes (q1 to q3) — category/subcategory/median are only used by the
    # HoverTool tooltip on the HTML artifact (LM-02); they don't affect the PNG.
    box_source = ColumnDataSource(
        data={
            "x": x_positions,
            "bottom": boxes_q1,
            "top": boxes_q3,
            "category": categories,
            "subcategory": [sub] * len(categories),
            "median": boxes_q2,
        }
    )

    box_renderer = p.vbar(
        x="x",
        width=box_width,
        bottom="bottom",
        top="top",
        source=box_source,
        fill_color=color,
        fill_alpha=0.85,
        line_color=INK_SOFT,
        line_width=2,
    )
    box_renderers.append(box_renderer)

    # Draw median lines
    for i in range(len(categories)):
        p.segment(
            x0=[x_positions[i] - box_width / 2],
            y0=[boxes_q2[i]],
            x1=[x_positions[i] + box_width / 2],
            y1=[boxes_q2[i]],
            line_color=INK,
            line_width=4,
            line_cap="round",
        )

    # Draw outliers
    if all_outliers_x:
        p.scatter(
            x=all_outliers_x,
            y=all_outliers_y,
            size=18,
            color=color,
            alpha=0.9,
            line_color=INK_SOFT,
            line_width=2,
            marker="circle",
        )

    # Store for legend
    legend_items.append(LegendItem(label=sub, renderers=[box_renderer]))

# Add legend
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="30pt",
    label_text_color=INK_SOFT,
    glyph_width=40,
    glyph_height=40,
    spacing=15,
    padding=20,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.9,
    border_line_color=INK_SOFT,
    border_line_width=2,
)
p.add_layout(legend, "right")

# Bokeh-distinctive interactivity (LM-02): hover tooltips on the HTML
# artifact — no effect on the static PNG, which is captured without a
# mouse position.
hover = HoverTool(
    renderers=box_renderers,
    tooltips=[
        ("Department", "@category"),
        ("Level", "@subcategory"),
        ("Median", "@median{0.0}"),
        ("Q1 – Q3", "@bottom{0.0} – @top{0.0}"),
    ],
)
p.add_tools(hover)

# Ticks live at the same 1, 2, 3, ... synthetic positions used for x_pos above
p.xaxis.ticker = FixedTicker(ticks=list(range(1, len(categories) + 1)))
p.xaxis.major_label_overrides = {i + 1: cat for i, cat in enumerate(categories)}

# Data storytelling (DE-03): call out the department with the largest
# Junior -> Lead score gap with a connecting arrow + label, derived from the
# medians actually computed above (not hardcoded).
highlight_cat = max(categories, key=lambda cat: medians["Lead"][cat] - medians["Junior"][cat])
highlight_gap = medians["Lead"][highlight_cat] - medians["Junior"][highlight_cat]
highlight_idx = categories.index(highlight_cat) + 1  # 1-indexed synthetic x position
x_junior = highlight_idx + offsets[0]
x_lead = highlight_idx + offsets[-1]
annotation_y = min(104, max_y_per_cat[highlight_cat] + 6)

p.add_layout(
    Arrow(
        x_start=x_junior,
        y_start=annotation_y,
        x_end=x_lead,
        y_end=annotation_y,
        start=NormalHead(size=8, fill_color=INK_SOFT, line_color=INK_SOFT),
        end=NormalHead(size=8, fill_color=INK_SOFT, line_color=INK_SOFT),
        line_color=INK_SOFT,
        line_width=2,
    )
)
p.add_layout(
    Label(
        x=(x_junior + x_lead) / 2,
        y=annotation_y + 3,
        text=f"Junior → Lead: +{highlight_gap:.0f} pts",
        text_font_size="22pt",
        text_font_style="italic",
        text_color=INK_SOFT,
        text_align="center",
        text_baseline="bottom",
    )
)

# Save the interactive HTML (also a required catalog artifact)
output_file(f"plot-{THEME}.html", title="box-grouped · python · bokeh · anyplot.ai")
save(p)

# Screenshot it with headless Chrome — bokeh.export_png() is unreliable on this
# box (chromedriver snap shim), so render + screenshot the saved HTML instead,
# matching the pattern in prompts/library/bokeh.md.
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.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
# Headless Chrome's --window-size sets the OUTER window; pin the viewport exactly via CDP.
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
time.sleep(3)  # let bokeh's JS render the canvas
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/box-grouped/bokeh/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "box-grouped",
  "language": "python",
  "library": "bokeh",
  "page": "https://anyplot.ai/box-grouped/python/bokeh",
  "hub": "https://anyplot.ai/box-grouped",
  "code_json": "https://api.anyplot.ai/specs/box-grouped/bokeh/code",
  "spec_json": "https://api.anyplot.ai/specs/box-grouped",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/bokeh/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/bokeh/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/bokeh/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/box-grouped/python/bokeh/plot-dark.html",
  "quality_score": 91.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Grouped Box Plot on anyplot.ai.

Other implementations