Grouped Violin Plot with Swarm Overlay — Makie.jl

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 Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 92/100 | Created: 2026-09-02

using CairoMakie
using Colors
using Random

Random.seed!(42)

# --- Theme tokens -----------------------------------------------------------
const THEME    = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG  = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const INK      = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"
const IMPRINT_PALETTE = [
    colorant"#009E73", colorant"#C475FD", colorant"#4467A3", colorant"#BD8233",
    colorant"#AE3030", colorant"#2ABCCD", colorant"#954477", colorant"#99B314",
]

# --- Data ---------------------------------------------------------------
# Response times (ms) for 3 task types, split by 2 expertise levels.
task_types = ["Search", "Compute", "Render"]
expertise_levels = ["Junior", "Senior"]
n_per_group = 40

log_median = Dict("Search" => 4.2, "Compute" => 4.6, "Render" => 5.0)
log_spread = Dict("Search" => 0.28, "Compute" => 0.3, "Render" => 0.33)
expertise_shift = Dict("Junior" => 0.25, "Senior" => -0.15)

task_idx = Int[]
expertise_idx = Int[]
response_time = Float64[]

for (ti, task) in enumerate(task_types), (ei, level) in enumerate(expertise_levels)
    times = exp.(log_median[task] .+ expertise_shift[level] .+ log_spread[task] .* randn(n_per_group))
    append!(task_idx, fill(ti, n_per_group))
    append!(expertise_idx, fill(ei, n_per_group))
    append!(response_time, times)
end

point_color = [IMPRINT_PALETTE[i] for i in expertise_idx]

# --- Dodge geometry (mirrors Makie's internal violin dodge math, so swarm
# points line up with their violin's footprint) ------------------------------
const GAP = 0.2
const DODGE_GAP = 0.05
const N_DODGE = length(expertise_levels)

scale_width(dodge_gap, n_dodge) = (1 - (n_dodge - 1) * dodge_gap) / n_dodge
shift_dodge(i, dodge_width, dodge_gap) = (dodge_width - 1) / 2 + (i - 1) * (dodge_width + dodge_gap)

violin_slot_width = (1 - GAP) * scale_width(DODGE_GAP, N_DODGE)
slot_center(task, expertise) = task + (1 - GAP) * shift_dodge(expertise, scale_width(DODGE_GAP, N_DODGE), DODGE_GAP)

# --- Beeswarm layout: bin observations by value, spread symmetrically
# around the violin's dodge slot so overlapping points fan out sideways. ----
function beeswarm_offsets(values, nbins, step, max_offset)
    ymin, ymax = extrema(values)
    binwidth = (ymax - ymin) / nbins
    order = sortperm(values)
    bin_counts = zeros(Int, nbins)
    offsets = zeros(length(values))
    for i in order
        b = binwidth > 0 ? clamp(floor(Int, (values[i] - ymin) / binwidth) + 1, 1, nbins) : 1
        c = bin_counts[b]
        rank = (c + 1) ÷ 2
        side = iseven(c) ? 1 : -1
        offsets[i] = clamp(side * rank * step, -max_offset, max_offset)
        bin_counts[b] += 1
    end
    return offsets
end

swarm_x = zeros(length(response_time))
max_swarm_offset = violin_slot_width / 2 * 0.85
for ti in eachindex(task_types), ei in eachindex(expertise_levels)
    mask = (task_idx .== ti) .& (expertise_idx .== ei)
    offsets = beeswarm_offsets(response_time[mask], 16, 0.018, max_swarm_offset)
    swarm_x[mask] .= slot_center(ti, ei) .+ offsets
end

# --- Plot -----------------------------------------------------------------
title_str = "violin-grouped-swarm · julia · makie · anyplot.ai"

fig = Figure(
    resolution      = (1600, 900),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title              = title_str,
    titlesize          = 20,
    titlecolor         = INK,
    xlabel             = "Task Type",
    ylabel             = "Response Time (ms)",
    xlabelsize         = 14,
    ylabelsize         = 14,
    xlabelcolor        = INK,
    ylabelcolor        = INK,
    xticklabelsize     = 12,
    yticklabelsize     = 12,
    xticklabelcolor    = INK_SOFT,
    yticklabelcolor    = INK_SOFT,
    xtickcolor         = INK_SOFT,
    ytickcolor         = INK_SOFT,
    backgroundcolor    = PAGE_BG,
    topspinevisible    = false,
    rightspinevisible  = false,
    leftspinecolor     = INK_SOFT,
    bottomspinecolor   = INK_SOFT,
    xgridvisible       = false,
    ygridcolor         = RGBAf(INK.r, INK.g, INK.b, 0.15),
    xticks             = (1:length(task_types), task_types),
    limits             = (0.5, length(task_types) + 0.5, nothing, nothing),
)

violin!(
    ax, task_idx, response_time;
    dodge = expertise_idx, n_dodge = N_DODGE, gap = GAP, dodge_gap = DODGE_GAP,
    color = [RGBAf(c.r, c.g, c.b, 0.5) for c in point_color],
    strokecolor = INK_SOFT, strokewidth = 1, datalimits = (0, Inf),
)

scatter!(
    ax, swarm_x, response_time;
    color = point_color, markersize = 6, strokewidth = 0.5, strokecolor = PAGE_BG,
)

# --- Focal annotation: call out the group with the widest spread -----------
focal_task  = findfirst(==("Render"), task_types)
focal_level = findfirst(==("Junior"), expertise_levels)
focal_mask  = (task_idx .== focal_task) .& (expertise_idx .== focal_level)
focal_x     = slot_center(focal_task, focal_level)
focal_y     = maximum(response_time[focal_mask])
text!(
    ax, focal_x, focal_y;
    text = "Widest spread", align = (:left, :bottom), offset = (10, 2),
    color = INK_SOFT, fontsize = 11, font = :italic,
)

legend_elements = [MarkerElement(color = IMPRINT_PALETTE[i], marker = :circle, markersize = 14) for i in eachindex(expertise_levels)]
Legend(
    fig[1, 2], legend_elements, expertise_levels, "Expertise Level";
    labelcolor = INK_SOFT, titlecolor = INK, framevisible = false,
    labelsize = 12, titlesize = 14,
)
colsize!(fig.layout, 2, Fixed(160))

# --- Save -------------------------------------------------------------------
save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/violin-grouped-swarm/makie/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": "violin-grouped-swarm",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/violin-grouped-swarm/julia/makie",
  "hub": "https://anyplot.ai/violin-grouped-swarm",
  "code_json": "https://api.anyplot.ai/specs/violin-grouped-swarm/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/violin-grouped-swarm",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/julia/makie/plot-dark.png",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations