{"spec_id":"violin-grouped-swarm","library":"makie","language":"julia","code":"# anyplot.ai\n# violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 92/100 | Created: 2026-09-02\n\nusing CairoMakie\nusing Colors\nusing Random\n\nRandom.seed!(42)\n\n# --- Theme tokens -----------------------------------------------------------\nconst THEME    = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG  = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst INK      = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\nconst IMPRINT_PALETTE = [\n    colorant\"#009E73\", colorant\"#C475FD\", colorant\"#4467A3\", colorant\"#BD8233\",\n    colorant\"#AE3030\", colorant\"#2ABCCD\", colorant\"#954477\", colorant\"#99B314\",\n]\n\n# --- Data ---------------------------------------------------------------\n# Response times (ms) for 3 task types, split by 2 expertise levels.\ntask_types = [\"Search\", \"Compute\", \"Render\"]\nexpertise_levels = [\"Junior\", \"Senior\"]\nn_per_group = 40\n\nlog_median = Dict(\"Search\" => 4.2, \"Compute\" => 4.6, \"Render\" => 5.0)\nlog_spread = Dict(\"Search\" => 0.28, \"Compute\" => 0.3, \"Render\" => 0.33)\nexpertise_shift = Dict(\"Junior\" => 0.25, \"Senior\" => -0.15)\n\ntask_idx = Int[]\nexpertise_idx = Int[]\nresponse_time = Float64[]\n\nfor (ti, task) in enumerate(task_types), (ei, level) in enumerate(expertise_levels)\n    times = exp.(log_median[task] .+ expertise_shift[level] .+ log_spread[task] .* randn(n_per_group))\n    append!(task_idx, fill(ti, n_per_group))\n    append!(expertise_idx, fill(ei, n_per_group))\n    append!(response_time, times)\nend\n\npoint_color = [IMPRINT_PALETTE[i] for i in expertise_idx]\n\n# --- Dodge geometry (mirrors Makie's internal violin dodge math, so swarm\n# points line up with their violin's footprint) ------------------------------\nconst GAP = 0.2\nconst DODGE_GAP = 0.05\nconst N_DODGE = length(expertise_levels)\n\nscale_width(dodge_gap, n_dodge) = (1 - (n_dodge - 1) * dodge_gap) / n_dodge\nshift_dodge(i, dodge_width, dodge_gap) = (dodge_width - 1) / 2 + (i - 1) * (dodge_width + dodge_gap)\n\nviolin_slot_width = (1 - GAP) * scale_width(DODGE_GAP, N_DODGE)\nslot_center(task, expertise) = task + (1 - GAP) * shift_dodge(expertise, scale_width(DODGE_GAP, N_DODGE), DODGE_GAP)\n\n# --- Beeswarm layout: bin observations by value, spread symmetrically\n# around the violin's dodge slot so overlapping points fan out sideways. ----\nfunction beeswarm_offsets(values, nbins, step, max_offset)\n    ymin, ymax = extrema(values)\n    binwidth = (ymax - ymin) / nbins\n    order = sortperm(values)\n    bin_counts = zeros(Int, nbins)\n    offsets = zeros(length(values))\n    for i in order\n        b = binwidth > 0 ? clamp(floor(Int, (values[i] - ymin) / binwidth) + 1, 1, nbins) : 1\n        c = bin_counts[b]\n        rank = (c + 1) ÷ 2\n        side = iseven(c) ? 1 : -1\n        offsets[i] = clamp(side * rank * step, -max_offset, max_offset)\n        bin_counts[b] += 1\n    end\n    return offsets\nend\n\nswarm_x = zeros(length(response_time))\nmax_swarm_offset = violin_slot_width / 2 * 0.85\nfor ti in eachindex(task_types), ei in eachindex(expertise_levels)\n    mask = (task_idx .== ti) .& (expertise_idx .== ei)\n    offsets = beeswarm_offsets(response_time[mask], 16, 0.018, max_swarm_offset)\n    swarm_x[mask] .= slot_center(ti, ei) .+ offsets\nend\n\n# --- Plot -----------------------------------------------------------------\ntitle_str = \"violin-grouped-swarm · julia · makie · anyplot.ai\"\n\nfig = Figure(\n    resolution      = (1600, 900),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title              = title_str,\n    titlesize          = 20,\n    titlecolor         = INK,\n    xlabel             = \"Task Type\",\n    ylabel             = \"Response Time (ms)\",\n    xlabelsize         = 14,\n    ylabelsize         = 14,\n    xlabelcolor        = INK,\n    ylabelcolor        = INK,\n    xticklabelsize     = 12,\n    yticklabelsize     = 12,\n    xticklabelcolor    = INK_SOFT,\n    yticklabelcolor    = INK_SOFT,\n    xtickcolor         = INK_SOFT,\n    ytickcolor         = INK_SOFT,\n    backgroundcolor    = PAGE_BG,\n    topspinevisible    = false,\n    rightspinevisible  = false,\n    leftspinecolor     = INK_SOFT,\n    bottomspinecolor   = INK_SOFT,\n    xgridvisible       = false,\n    ygridcolor         = RGBAf(INK.r, INK.g, INK.b, 0.15),\n    xticks             = (1:length(task_types), task_types),\n    limits             = (0.5, length(task_types) + 0.5, nothing, nothing),\n)\n\nviolin!(\n    ax, task_idx, response_time;\n    dodge = expertise_idx, n_dodge = N_DODGE, gap = GAP, dodge_gap = DODGE_GAP,\n    color = [RGBAf(c.r, c.g, c.b, 0.5) for c in point_color],\n    strokecolor = INK_SOFT, strokewidth = 1, datalimits = (0, Inf),\n)\n\nscatter!(\n    ax, swarm_x, response_time;\n    color = point_color, markersize = 6, strokewidth = 0.5, strokecolor = PAGE_BG,\n)\n\n# --- Focal annotation: call out the group with the widest spread -----------\nfocal_task  = findfirst(==(\"Render\"), task_types)\nfocal_level = findfirst(==(\"Junior\"), expertise_levels)\nfocal_mask  = (task_idx .== focal_task) .& (expertise_idx .== focal_level)\nfocal_x     = slot_center(focal_task, focal_level)\nfocal_y     = maximum(response_time[focal_mask])\ntext!(\n    ax, focal_x, focal_y;\n    text = \"Widest spread\", align = (:left, :bottom), offset = (10, 2),\n    color = INK_SOFT, fontsize = 11, font = :italic,\n)\n\nlegend_elements = [MarkerElement(color = IMPRINT_PALETTE[i], marker = :circle, markersize = 14) for i in eachindex(expertise_levels)]\nLegend(\n    fig[1, 2], legend_elements, expertise_levels, \"Expertise Level\";\n    labelcolor = INK_SOFT, titlecolor = INK, framevisible = false,\n    labelsize = 12, titlesize = 14,\n)\ncolsize!(fig.layout, 2, Fixed(160))\n\n# --- Save -------------------------------------------------------------------\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}