Basic Swarm Plot — Altair

A swarm plot (beeswarm plot) displays individual data points for categorical comparisons, with points spread horizontally to avoid overlap. This reveals the full distribution shape and density while preserving exact values - combining the benefits of strip plots (individual points) and violin plots (density visualization). Ideal when you need to see every observation rather than just summary statistics.

Basic Swarm Plot rendered with Altair

Python source (Altair)

""" anyplot.ai
swarm-basic: Basic Swarm Plot
Library: altair 6.2.2 | Python 3.13.14
Quality: 90/100 | Updated: 2026-07-26
"""

import os

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


# 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"

IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]

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

departments = ["Engineering", "Marketing", "Sales", "HR"]
n_per_dept = [45, 38, 52, 35]

data = []
for dept, n in zip(departments, n_per_dept, strict=True):
    if dept == "Engineering":
        scores = np.random.normal(78, 8, n)
    elif dept == "Marketing":
        scores = np.random.normal(72, 12, n)
    elif dept == "Sales":
        scores = np.concatenate([np.random.normal(65, 6, n // 2), np.random.normal(82, 5, n - n // 2)])
    else:  # HR
        scores = np.concatenate([np.random.normal(68, 9, n - 3), np.array([45, 92, 95])])
    scores = np.clip(scores, 30, 100)
    for score in scores:
        data.append({"Department": dept, "Performance Score": score})

df = pd.DataFrame(data)

# Numeric x position per department, with jitter drawn from the same seeded
# generator as the scores above — deterministic across renders, unlike
# Vega-Lite's in-chart random() which reseeds on every render.
dept_positions = {dept: i for i, dept in enumerate(departments)}
df["x_pos"] = df["Department"].map(dept_positions)
df["x_jitter"] = df["x_pos"] + np.random.uniform(-0.28, 0.28, len(df))

# Calculate means
means = df.groupby("Department")["Performance Score"].mean().reset_index()
means["x_pos"] = means["Department"].map(dept_positions)

# Flag outliers (>2 std from the department mean) for a hierarchy-emphasis layer
df["dept_mean"] = df["Department"].map(means.set_index("Department")["Performance Score"])
df["dept_std"] = df["Department"].map(df.groupby("Department")["Performance Score"].std())
outliers = df[(df["Performance Score"] - df["dept_mean"]).abs() > 2 * df["dept_std"]]

# Swarm points — jitter precomputed in pandas (see above) for reproducibility.
# A thin background-matching stroke cuts a halo around each circle so dense
# clusters (Sales, Engineering) stay individually distinguishable.
swarm = (
    alt.Chart(df)
    .mark_circle(size=140, opacity=0.75, stroke=PAGE_BG, strokeWidth=0.6)
    .encode(
        x=alt.X(
            "x_jitter:Q",
            scale=alt.Scale(domain=[-0.65, 3.65]),
            axis=alt.Axis(
                values=list(range(4)),
                labelExpr="['Engineering', 'Marketing', 'Sales', 'HR'][datum.value]",
                title="Department",
                grid=False,
                labelAngle=0,
            ),
        ),
        y=alt.Y("Performance Score:Q", scale=alt.Scale(domain=[30, 100])),
        color=alt.Color(
            "Department:N", scale=alt.Scale(domain=departments, range=IMPRINT), legend=alt.Legend(orient="right")
        ),
        tooltip=["Department", "Performance Score"],
    )
)

# Mean diamond markers (theme-adaptive color)
mean_markers = (
    alt.Chart(means)
    .mark_point(shape="diamond", size=260, filled=True, color=INK, strokeWidth=1.5)
    .encode(
        x="x_pos:Q",
        y="Performance Score:Q",
        tooltip=[alt.Tooltip("Department"), alt.Tooltip("Performance Score:Q", title="Mean", format=".1f")],
    )
)

# Mean reference lines (theme-adaptive color)
mean_lines = (
    alt.Chart(means)
    .mark_rule(color=INK, strokeWidth=1.5, strokeDash=[4, 4])
    .encode(x=alt.X("x_start:Q"), x2="x_end:Q", y="Performance Score:Q")
    .transform_calculate(x_start="datum.x_pos - 0.35", x_end="datum.x_pos + 0.35")
)

# Outlier rings — unfilled ink-stroke circles emphasize the notable
# out-of-band observations (e.g. the HR 45/92/95 points) as a hierarchy cue
outlier_rings = (
    alt.Chart(outliers)
    .mark_point(shape="circle", size=220, filled=False, stroke=INK, strokeWidth=1.5)
    .encode(x="x_jitter:Q", y="Performance Score:Q")
)

# Compose and apply theme-adaptive chrome
chart = (
    (swarm + mean_lines + outlier_rings + mean_markers)
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        title=alt.Title("swarm-basic · altair · anyplot.ai", fontSize=16, anchor="middle"),
    )
    .configure_axis(
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        gridColor=INK,
        gridOpacity=0.12,
        labelColor=INK_SOFT,
        titleColor=INK,
        labelFontSize=10,
        titleFontSize=12,
    )
    .configure_view(fill=PAGE_BG, stroke=None)
    .configure_title(color=INK)
    .configure_legend(
        fillColor=ELEVATED_BG,
        strokeColor=INK_SOFT,
        labelColor=INK_SOFT,
        titleColor=INK,
        labelFontSize=10,
        titleFontSize=10,
    )
)

# Save — hard target 3200x1800 (landscape). vl-convert pads the small inner
# view with title/axis/legend extents, then we pad-only to the exact target.
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(f"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. Shrink chart dims.")
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Part of Basic Swarm Plot on anyplot.ai.

Other implementations