Box Plot with Strip Overlay — Pygal

A combined visualization that overlays individual data points (strip plot) on top of a box plot. This provides both summary statistics (median, quartiles, whiskers) and visibility of the actual data distribution.

Box Plot with Strip Overlay rendered with Pygal

Python source (Pygal)

""" anyplot.ai
cat-box-strip: Box Plot with Strip Overlay
Library: pygal 3.1.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-05-13
"""

import os

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens from environment
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Okabe-Ito palette (colorblind-safe, first series is brand green #009E73)
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")

# Data - Test scores across education levels
np.random.seed(42)
categories = ["High School", "Bachelor's", "Master's", "PhD"]
data = {
    "High School": np.random.normal(68, 12, 45),
    "Bachelor's": np.random.normal(78, 10, 50),
    "Master's": np.random.normal(82, 8, 38),
    "PhD": np.random.normal(85, 7, 42),
}

# Add realistic outliers
data["High School"] = np.append(data["High School"], [95, 48])
data["Bachelor's"] = np.append(data["Bachelor's"], [58, 98])
data["Master's"] = np.append(data["Master's"], [62, 95])
data["PhD"] = np.append(data["PhD"], [65, 100])

# Clamp all values to 0-100 range
for key in data:
    data[key] = np.clip(data[key], 0, 100)

# Color sequence: 6 colors for box plot elements + 4 for strip points (one per category)
color_sequence = []
for i in range(4):
    color_sequence.extend([IMPRINT[i]] * 6)  # 6 box elements per category
for i in range(4):
    color_sequence.append(IMPRINT[i])  # 1 strip series per category

# Custom style for 4800x2700 px canvas with theme-adaptive colors
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=tuple(color_sequence),
    title_font_size=28,
    label_font_size=22,
    major_label_font_size=18,
    legend_font_size=16,
    value_font_size=14,
    opacity=0.7,
)

# Create XY chart for combined box plot with strip overlay
chart = pygal.XY(
    width=4800,
    height=2700,
    style=custom_style,
    title="cat-box-strip · pygal · anyplot.ai",
    x_title="Education Level",
    y_title="Test Score",
    show_legend=False,
    stroke=True,
    fill=True,
    dots_size=0,
    show_x_guides=True,
    show_y_guides=True,
    xrange=(0, 5),
    range=(0, 105),
    margin=100,
    explicit_size=True,
)

# Layout parameters
box_width = 0.25
cap_width = 0.15

# Pre-compute box plot components and strip points
strip_data = []
box_data = []

for i, (category, values) in enumerate(data.items()):
    center_x = i + 1  # X position for this group (1, 2, 3, 4)
    values = np.array(values)

    # --- Box Plot Statistics ---
    median = float(np.median(values))
    q1 = float(np.percentile(values, 25))
    q3 = float(np.percentile(values, 75))
    iqr = q3 - q1
    whisker_low = float(max(values.min(), q1 - 1.5 * iqr))
    whisker_high = float(min(values.max(), q3 + 1.5 * iqr))
    box_data.append((center_x, median, q1, q3, whisker_low, whisker_high))

    # --- Strip Points with Jitter ---
    np.random.seed(42 + i)
    jitter = np.random.uniform(-0.12, 0.12, len(values))
    strip_points = [(center_x + j, float(v)) for j, v in zip(jitter, values, strict=True)]
    strip_data.append((category, strip_points))

# Draw box plots first (so strip points appear on top)
for center_x, median, q1, q3, whisker_low, whisker_high in box_data:
    # IQR box (filled rectangle)
    quartile_box = [
        (center_x - box_width, q1),
        (center_x - box_width, q3),
        (center_x + box_width, q3),
        (center_x + box_width, q1),
        (center_x - box_width, q1),
    ]
    chart.add("", quartile_box, stroke=True, fill=True, show_dots=False, stroke_style={"width": 6})

    # Median line (horizontal line within box)
    median_line = [(center_x - box_width * 1.1, median), (center_x + box_width * 1.1, median)]
    chart.add("", median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 10})

    # Whiskers (vertical lines from box to caps)
    whisker_bottom = [(center_x, q1), (center_x, whisker_low)]
    whisker_top = [(center_x, q3), (center_x, whisker_high)]
    chart.add("", whisker_bottom, stroke=True, fill=False, show_dots=False, stroke_style={"width": 6})
    chart.add("", whisker_top, stroke=True, fill=False, show_dots=False, stroke_style={"width": 6})

    # Whisker caps (horizontal lines at ends)
    cap_bottom = [(center_x - cap_width, whisker_low), (center_x + cap_width, whisker_low)]
    cap_top = [(center_x - cap_width, whisker_high), (center_x + cap_width, whisker_high)]
    chart.add("", cap_bottom, stroke=True, fill=False, show_dots=False, stroke_style={"width": 6})
    chart.add("", cap_top, stroke=True, fill=False, show_dots=False, stroke_style={"width": 6})

# Add strip points on top with transparency
for _category, strip_points in strip_data:
    chart.add("", strip_points, stroke=False, fill=False, dots_size=16)

# X-axis labels for categories
chart.x_labels = [
    {"value": 0, "label": ""},
    {"value": 1, "label": "High School"},
    {"value": 2, "label": "Bachelor's"},
    {"value": 3, "label": "Master's"},
    {"value": 4, "label": "PhD"},
    {"value": 5, "label": ""},
]

# Save outputs
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Part of Box Plot with Strip Overlay on anyplot.ai.

Other implementations