Notched Box Plot — Pygal

A notched box plot extends the standard box plot by adding notches around the median that represent a confidence interval. If the notches of two boxes do not overlap, this provides visual evidence that the medians differ significantly. This variant is particularly valuable for quick visual hypothesis testing and comparing group medians in statistical analysis.

Notched Box Plot rendered with Pygal

Python source (Pygal)

""" anyplot.ai
box-notched: Notched Box Plot
Library: pygal 3.1.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-07
"""

import os
import xml.etree.ElementTree as ET

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


# Theme tokens
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"
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030")

# Data - Generate response times for different server configurations
np.random.seed(42)
categories = ["Baseline", "Config A", "Config B", "Config C", "Config D"]
data = {
    "Baseline": np.random.normal(120, 25, 80),
    "Config A": np.random.normal(95, 20, 80),
    "Config B": np.random.normal(115, 22, 80),
    "Config C": np.random.normal(85, 18, 80),
    "Config D": np.random.normal(110, 30, 80),
}
data["Baseline"] = np.append(data["Baseline"], [200, 210, 45])
data["Config D"] = np.append(data["Config D"], [190, 35])

# Calculate notched box plot statistics (inlined)
stats = {}
for cat in categories:
    values = data[cat]
    q1 = np.percentile(values, 25)
    median = np.percentile(values, 50)
    q3 = np.percentile(values, 75)
    iqr = q3 - q1
    n = len(values)

    notch_width = 1.57 * iqr / np.sqrt(n)
    notch_low = median - notch_width
    notch_high = median + notch_width

    whisker_low = max(q1 - 1.5 * iqr, np.min(values))
    whisker_high = min(q3 + 1.5 * iqr, np.max(values))

    outliers = values[(values < q1 - 1.5 * iqr) | (values > q3 + 1.5 * iqr)]

    stats[cat] = {
        "q1": q1,
        "median": median,
        "q3": q3,
        "notch_low": notch_low,
        "notch_high": notch_high,
        "whisker_low": whisker_low,
        "whisker_high": whisker_high,
        "outliers": outliers.tolist(),
    }

# Custom style
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT,
    title_font_size=28,
    label_font_size=22,
    major_label_font_size=18,
    legend_font_size=16,
    value_font_size=14,
)

# Create base chart
all_values = np.concatenate([data[cat] for cat in categories])
y_min = np.floor(np.min(all_values) / 10) * 10 - 10
y_max = np.ceil(np.max(all_values) / 10) * 10 + 10

chart = pygal.Line(
    width=4800,
    height=2700,
    style=custom_style,
    title="box-notched · pygal · anyplot.ai",
    x_title="Server Configuration",
    y_title="Response Time (ms)",
    show_legend=True,
    legend_at_bottom=True,
    legend_box_size=24,
    show_y_guides=True,
    show_x_guides=False,
    margin=50,
    range=(y_min, y_max),
    show_dots=False,
    stroke=False,
    fill=False,
    no_data_text="",
)

chart.x_labels = categories

for category in categories:
    chart.add(category, [{"value": y_min, "label": ""}])

# Render base SVG
svg_string = chart.render()

# Parse and modify SVG
ET.register_namespace("", "http://www.w3.org/2000/svg")
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
root = ET.fromstring(svg_string)

# Plot area boundaries
plot_left = 350
plot_right = 4600
plot_top = 200
plot_bottom = 2350
plot_width = plot_right - plot_left
plot_height = plot_bottom - plot_top

boxes_group = ET.Element("{http://www.w3.org/2000/svg}g", attrib={"class": "notched-boxes"})

n_cats = len(categories)
box_spacing = plot_width / n_cats
box_width = box_spacing * 0.6
notch_indent = box_width * 0.15

# Draw each notched box
for i, category in enumerate(categories):
    s = stats[category]
    color = IMPRINT[i % len(IMPRINT)]
    x_center = plot_left + box_spacing * (i + 0.5)
    x_left = x_center - box_width / 2
    x_right = x_center + box_width / 2

    y_q1 = plot_bottom - (s["q1"] - y_min) / (y_max - y_min) * plot_height
    y_q3 = plot_bottom - (s["q3"] - y_min) / (y_max - y_min) * plot_height
    y_med = plot_bottom - (s["median"] - y_min) / (y_max - y_min) * plot_height
    y_notch_low = plot_bottom - (s["notch_low"] - y_min) / (y_max - y_min) * plot_height
    y_notch_high = plot_bottom - (s["notch_high"] - y_min) / (y_max - y_min) * plot_height
    y_whisker_low = plot_bottom - (s["whisker_low"] - y_min) / (y_max - y_min) * plot_height
    y_whisker_high = plot_bottom - (s["whisker_high"] - y_min) / (y_max - y_min) * plot_height

    notch_x_left = x_left + notch_indent
    notch_x_right = x_right - notch_indent

    path_d = (
        f"M {x_left} {y_q3} "
        f"L {x_right} {y_q3} "
        f"L {x_right} {y_notch_high} "
        f"L {notch_x_right} {y_med} "
        f"L {x_right} {y_notch_low} "
        f"L {x_right} {y_q1} "
        f"L {x_left} {y_q1} "
        f"L {x_left} {y_notch_low} "
        f"L {notch_x_left} {y_med} "
        f"L {x_left} {y_notch_high} "
        f"Z"
    )

    box_path = ET.SubElement(
        boxes_group,
        "{http://www.w3.org/2000/svg}path",
        attrib={"d": path_d, "fill": color, "fill-opacity": "0.4", "stroke": color, "stroke-width": "4"},
    )

    med_line = ET.SubElement(
        boxes_group,
        "{http://www.w3.org/2000/svg}line",
        attrib={
            "x1": str(notch_x_left),
            "y1": str(y_med),
            "x2": str(notch_x_right),
            "y2": str(y_med),
            "stroke": color,
            "stroke-width": "6",
        },
    )

    upper_whisker = ET.SubElement(
        boxes_group,
        "{http://www.w3.org/2000/svg}line",
        attrib={
            "x1": str(x_center),
            "y1": str(y_q3),
            "x2": str(x_center),
            "y2": str(y_whisker_high),
            "stroke": color,
            "stroke-width": "3",
        },
    )

    cap_width = box_width * 0.3
    upper_cap = ET.SubElement(
        boxes_group,
        "{http://www.w3.org/2000/svg}line",
        attrib={
            "x1": str(x_center - cap_width / 2),
            "y1": str(y_whisker_high),
            "x2": str(x_center + cap_width / 2),
            "y2": str(y_whisker_high),
            "stroke": color,
            "stroke-width": "3",
        },
    )

    lower_whisker = ET.SubElement(
        boxes_group,
        "{http://www.w3.org/2000/svg}line",
        attrib={
            "x1": str(x_center),
            "y1": str(y_q1),
            "x2": str(x_center),
            "y2": str(y_whisker_low),
            "stroke": color,
            "stroke-width": "3",
        },
    )

    lower_cap = ET.SubElement(
        boxes_group,
        "{http://www.w3.org/2000/svg}line",
        attrib={
            "x1": str(x_center - cap_width / 2),
            "y1": str(y_whisker_low),
            "x2": str(x_center + cap_width / 2),
            "y2": str(y_whisker_low),
            "stroke": color,
            "stroke-width": "3",
        },
    )

    for outlier in s["outliers"]:
        y_outlier = plot_bottom - (outlier - y_min) / (y_max - y_min) * plot_height
        ET.SubElement(
            boxes_group,
            "{http://www.w3.org/2000/svg}circle",
            attrib={
                "cx": str(x_center),
                "cy": str(y_outlier),
                "r": "12",
                "fill": PAGE_BG,
                "stroke": color,
                "stroke-width": "3",
            },
        )

root.append(boxes_group)
modified_svg = ET.tostring(root, encoding="unicode")

# Save as PNG and HTML
with open(f"plot-{THEME}.html", "w") as f:
    f.write(modified_svg)

cairosvg.svg2png(
    bytestring=modified_svg.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=4800, output_height=2700
)

Part of Notched Box Plot on anyplot.ai.

Other implementations