Grouped Box Plot — Makie.jl

A grouped box plot displays multiple box plots side-by-side within each category, enabling comparison of distributions across subgroups. Each group contains boxes representing different subcategories or conditions, making it ideal for multi-factor comparisons and A/B testing scenarios with multiple metrics.

Grouped Box Plot rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# box-grouped: Grouped Box Plot
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 93/100 | Created: 2026-08-18

using CairoMakie
using Colors
using Random
using Statistics

Random.seed!(42)

# Theme tokens (see prompts/default-style-guide.md "Theme-adaptive Chrome")
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"

# Imprint categorical palette — 8 hues, theme-independent, hybrid-v3 sort
const IMPRINT_PALETTE = [
    colorant"#009E73",  # 1 — brand green
    colorant"#C475FD",  # 2 — lavender
    colorant"#4467A3",  # 3 — blue
]

# Data — quarterly performance review scores by department and experience level
const DEPARTMENTS = ["Engineering", "Sales", "Marketing", "Support"]
const EXPERIENCE_LEVELS = ["Junior", "Mid-Level", "Senior"]
const DEPT_BASELINE = [72.0, 68.0, 70.0, 65.0]
const EXPERIENCE_BONUS = [0.0, 7.0, 15.0]
const N_PER_GROUP = 45

category_idx = Int[]
subgroup_idx = Int[]
performance_scores = Float64[]

for (ci, baseline) in enumerate(DEPT_BASELINE)
    for (si, bonus) in enumerate(EXPERIENCE_BONUS)
        scores = clamp.(baseline .+ bonus .+ randn(N_PER_GROUP) .* 7.0, 20.0, 100.0)
        append!(performance_scores, scores)
        append!(category_idx, fill(ci, N_PER_GROUP))
        append!(subgroup_idx, fill(si, N_PER_GROUP))
    end
end

point_colors = IMPRINT_PALETTE[subgroup_idx]

# Storytelling focal point — find the department where the Senior/Junior
# experience-tier gap is widest, to call it out with a comparison bracket.
const BOX_WIDTH     = 0.7
const BOX_GAP       = 0.3
const BOX_DODGE_GAP = 0.05
const N_DODGE       = length(EXPERIENCE_LEVELS)

# Mirrors Makie's internal `compute_x_and_width` dodge formula so the bracket
# lines up exactly with the boxplot's dodged box positions.
function dodge_x(category, subgroup_i)
    eff_width   = BOX_WIDTH * (1 - BOX_GAP)
    dodge_width = (1 - (N_DODGE - 1) * BOX_DODGE_GAP) / N_DODGE
    shift       = (dodge_width - 1) / 2 + (subgroup_i - 1) * (dodge_width + BOX_DODGE_GAP)
    return category + eff_width * shift
end

dept_gaps = [
    median(performance_scores[(category_idx .== ci) .& (subgroup_idx .== 3)]) -
    median(performance_scores[(category_idx .== ci) .& (subgroup_idx .== 1)])
    for ci in 1:length(DEPARTMENTS)
]
standout_ci  = argmax(dept_gaps)
standout_gap = dept_gaps[standout_ci]
bracket_x1   = dodge_x(standout_ci, 1)  # Junior box
bracket_x2   = dodge_x(standout_ci, 3)  # Senior box
bracket_y    = maximum(performance_scores[category_idx .== standout_ci]) + 4.0

# Plot — see default-style-guide.md "Visual Sizing Defaults" for canvas + sizing values
title_str = "box-grouped · 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,
    ylabel            = "Performance Score (0-100)",
    ylabelsize        = 14,
    ylabelcolor       = INK,
    xticks            = (1:length(DEPARTMENTS), DEPARTMENTS),
    xticklabelsize    = 12,
    yticklabelsize    = 12,
    xticklabelcolor   = INK_SOFT,
    yticklabelcolor   = 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),
)

boxplot!(
    ax, category_idx, performance_scores;
    dodge         = subgroup_idx,
    color         = point_colors,
    width         = 0.7,
    dodge_gap     = 0.05,
    gap           = 0.3,
    strokecolor   = INK,
    strokewidth   = 1,
    mediancolor   = INK,
    whiskercolor  = INK_SOFT,
    markersize    = 6,
)

bracket!(
    ax,
    bracket_x1, bracket_y, bracket_x2, bracket_y;
    text        = "$(DEPARTMENTS[standout_ci]): Senior +$(round(Int, standout_gap)) vs Junior",
    offset      = 6,
    width       = 10,
    orientation = :up,
    color       = INK_SOFT,
    textcolor   = INK,
    fontsize    = 13,
)
ylims!(ax, minimum(performance_scores) - 5.0, maximum(performance_scores) + 16.0)

legend_elements = [PolyElement(color = c, strokecolor = :transparent) for c in IMPRINT_PALETTE]
Legend(
    fig[1, 2], legend_elements, EXPERIENCE_LEVELS, "Experience Level";
    framevisible = false,
    labelcolor   = INK,
    titlecolor   = INK,
    labelsize    = 12,
    titlesize    = 12,
)
colsize!(fig.layout, 2, Relative(0.14))

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

Part of Grouped Box Plot on anyplot.ai.

Other implementations