Grouped Violin Plot with Swarm Overlay — Pygal

A grouped violin plot with individual data points overlaid as swarm points, showing distributions across two categorical dimensions simultaneously. Multiple violin plots are arranged by category on the x-axis and grouped by a secondary variable using color/hue, with swarm points revealing the underlying raw data. This visualization combines distribution shape visualization with complete data transparency, ideal for comparing how distributions differ across multiple factors.

Grouped Violin Plot with Swarm Overlay rendered with Pygal

Python source (Pygal)

""" anyplot.ai
violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay
Library: pygal 3.1.0 | Python 3.13.13
Quality: 80/100 | Updated: 2026-05-18
"""

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


# Data - Response times across task types and expertise levels
np.random.seed(42)
categories = ["Simple", "Medium", "Complex"]
groups = ["Novice", "Expert"]

# Generate realistic response time data (in milliseconds)
data = {}
for cat in categories:
    data[cat] = {}
    if cat == "Simple":
        data[cat]["Novice"] = np.random.normal(450, 80, 40)
        data[cat]["Expert"] = np.random.normal(280, 50, 40)
    elif cat == "Medium":
        data[cat]["Novice"] = np.random.normal(850, 150, 40)
        data[cat]["Expert"] = np.random.normal(520, 90, 40)
    else:  # Complex
        data[cat]["Novice"] = np.random.normal(1400, 250, 40)
        data[cat]["Expert"] = np.random.normal(780, 120, 40)

# Clip to realistic range
for cat in categories:
    for group in groups:
        data[cat][group] = np.clip(data[cat][group], 100, 2000)

# Colors for groups
novice_color = "#306998"  # Python Blue
expert_color = "#FFD43B"  # Python Yellow
swarm_novice = "#1a4d75"  # Darker blue for swarm
swarm_expert = "#c9a82c"  # Darker yellow for swarm

# Custom style for 4800x2700 px canvas
# Color order: 3 novice violins, 3 expert violins, 12 novice swarm chunks, 12 expert swarm chunks
custom_style = Style(
    background="white",
    plot_background="white",
    foreground="#333333",
    foreground_strong="#333333",
    foreground_subtle="#666666",
    guide_stroke_color="#e0e0e0",
    colors=(novice_color,) * 3 + (expert_color,) * 3 + (swarm_novice,) * 15 + (swarm_expert,) * 15,
    title_font_size=84,
    label_font_size=54,
    major_label_font_size=48,
    legend_font_size=48,
    value_font_size=36,
    opacity=0.4,  # Semi-transparent violins so swarm points show through
    opacity_hover=0.6,
)

# Create XY chart for grouped violin plot with swarm
chart = pygal.XY(
    width=4800,
    height=2700,
    style=custom_style,
    title="violin-grouped-swarm · pygal · pyplots.ai",
    x_title="Task Type",
    y_title="Response Time (ms)",
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=2,
    stroke=True,
    fill=True,
    dots_size=0,
    show_x_guides=False,
    show_y_guides=True,
    range=(0, 2100),
    xrange=(0, 4.5),
    margin=60,
)

# Parameters for violin shapes
violin_width = 0.25
n_points = 60
group_offset = 0.35  # Offset between grouped violins


# KDE helper function
def compute_kde(values, y_range):
    """Compute Gaussian KDE using Silverman's rule."""
    n = len(values)
    std = np.std(values)
    iqr = np.percentile(values, 75) - np.percentile(values, 25)
    bandwidth = 0.9 * min(std, iqr / 1.34) * n ** (-0.2)

    density = np.zeros_like(y_range)
    for v in values:
        density += np.exp(-0.5 * ((y_range - v) / bandwidth) ** 2)
    density /= n * bandwidth * np.sqrt(2 * np.pi)
    return density


# Swarm layout helper - arranges points to avoid overlap
def compute_swarm_positions(values, center_x, width=0.15):
    """Compute swarm positions to minimize overlap."""
    sorted_indices = np.argsort(values)
    positions = np.zeros(len(values))

    # Bin values and offset within bins
    y_sorted = values[sorted_indices]
    y_range = y_sorted.max() - y_sorted.min()
    bin_height = y_range / 15 if y_range > 0 else 1

    current_bin = []
    current_bin_y = y_sorted[0] if len(y_sorted) > 0 else 0

    for idx, y in enumerate(y_sorted):
        if y - current_bin_y > bin_height:
            # Process current bin - spread points horizontally
            n_in_bin = len(current_bin)
            if n_in_bin > 0:
                offsets = np.linspace(-width / 2, width / 2, n_in_bin) if n_in_bin > 1 else [0]
                for i, bin_idx in enumerate(current_bin):
                    positions[bin_idx] = center_x + offsets[i]
            current_bin = [sorted_indices[idx]]
            current_bin_y = y
        else:
            current_bin.append(sorted_indices[idx])

    # Process last bin
    n_in_bin = len(current_bin)
    if n_in_bin > 0:
        offsets = np.linspace(-width / 2, width / 2, n_in_bin) if n_in_bin > 1 else [0]
        for i, bin_idx in enumerate(current_bin):
            positions[bin_idx] = center_x + offsets[i]

    return positions


# Pre-compute all shapes
novice_violins = []
expert_violins = []
novice_swarms = []
expert_swarms = []

for i, category in enumerate(categories):
    base_x = i + 1.25

    for group in groups:
        values = data[category][group]
        offset = -group_offset if group == "Novice" else group_offset
        center_x = base_x + offset

        # Create range of y values for density
        y_min, y_max = values.min(), values.max()
        padding = (y_max - y_min) * 0.15
        y_range = np.linspace(y_min - padding, y_max + padding, n_points)

        # Compute KDE
        density = compute_kde(values, y_range)

        # Normalize density to desired width
        density = density / density.max() * violin_width

        # Create full violin shape (mirrored)
        left_points = [(center_x - d, y) for y, d in zip(y_range, density, strict=True)]
        right_points = [(center_x + d, y) for y, d in zip(y_range[::-1], density[::-1], strict=True)]
        violin_points = left_points + right_points + [left_points[0]]

        # Compute swarm positions
        swarm_x = compute_swarm_positions(values, center_x, width=violin_width * 0.7)
        swarm_points = list(zip(swarm_x, values, strict=True))

        if group == "Novice":
            novice_violins.append(violin_points)
            novice_swarms.extend(swarm_points)
        else:
            expert_violins.append(violin_points)
            expert_swarms.extend(swarm_points)

# Add violins with legend entries for first of each group
for i, violin in enumerate(novice_violins):
    label = "Novice" if i == 0 else None
    chart.add(label, violin, show_dots=False)

for i, violin in enumerate(expert_violins):
    label = "Expert" if i == 0 else None
    chart.add(label, violin, show_dots=False)

# Add swarm points as individual series with dots
# Group swarm points into chunks to reduce number of series
chunk_size = 10
novice_chunks = [novice_swarms[i : i + chunk_size] for i in range(0, len(novice_swarms), chunk_size)]
expert_chunks = [expert_swarms[i : i + chunk_size] for i in range(0, len(expert_swarms), chunk_size)]

for chunk in novice_chunks:
    chart.add(None, chunk, stroke=False, fill=False, show_dots=True, dots_size=8)

for chunk in expert_chunks:
    chart.add(None, chunk, stroke=False, fill=False, show_dots=True, dots_size=8)

# X-axis labels for categories
chart.x_labels = [
    {"value": 0, "label": ""},
    {"value": 1.25, "label": "Simple"},
    {"value": 2.25, "label": "Medium"},
    {"value": 3.25, "label": "Complex"},
    {"value": 4.5, "label": ""},
]

# Save outputs
chart.render_to_file("plot.html")
chart.render_to_png("plot.png")

Part of Grouped Violin Plot with Swarm Overlay on anyplot.ai.

Other implementations