Skip to content

Beeswarm recipe

julia
export beeswarm, beeswarm!

export NoBeeswarm

In this file, we define the Beeswarm recipe.

julia
"""
    beeswarm(x, y)
    beeswarm(positions)

`beeswarm` is a `PointBased` recipe like `scatter`, accepting all of `scatter`'s input.

It displaces points which would otherwise overlap in the x-direction by binning in the y direction.

Specific attributes to `beeswarm` are:
- `algorithm = SimpleBeeswarm2()`: The algorithm used to lay out the beeswarm markers.
- `side = :both`: The side towards which markers should extend.  Can be `:left`, `:right`, or both.
- `direction = :y`: Controls the direction of the beeswarm.  Can be `:y` (vertical) or `:x` (horizontal).
- `gutter = nothing`: Creates a gutter of a desired size around each category.  Gutter size is always in data space.
- `gutter_threshold = .5`: Emit a warning of the number of points added to a gutter per category exceeds the threshold.

# Arguments
$(Makie.ATTRIBUTES)

# Example

```julia
using Makie, SwarmMakie
beeswarm(ones(100), randn(100); color = rand(RGBf, 100))
```
"""
@recipe(Beeswarm, positions) do scene
    return merge(
        Attributes(
            algorithm = SimpleBeeswarm2(),
            side = :both,
            direction = :y,
            gutter = nothing,
            gutter_threshold = .5,
        ),
        default_theme(scene, Scatter),
    )
end

Makie.conversion_trait(::Type{<: Beeswarm}) = Makie.PointBased()

this is subtyped by e.g. SimpleBeeswarm and VerticallyChallengedBeeswarm

julia
abstract type BeeswarmAlgorithm end

This is mostly useful to test the recipe...

julia
"A simple no-op algorithm, which causes the scatter plot to be drawn as if you called `scatter` and not `beeswarm`."
struct NoBeeswarm <: BeeswarmAlgorithm
end

function calculate!(buffer::AbstractVector{<: Point2}, alg::NoBeeswarm, positions::AbstractVector{<: Point2}, markersize, side::Symbol)
    @debug "Calculating..."
    buffer .= positions
    return
end

Beeswarm plots inherently have an extent that is dependent on the placement algorithm and the available space. However, you cannot use the actual placement of scatter dots to infer limits, because adjusting the axis given these limits invalidates the limits again, and so on, potentially ad infinitum.

Instead, it makes more sense to pick fixed limits given the input data. If that doesn't leave enough place for all beeswarms, probably the axis size has to be increased, or the marker sized decreased, anyway.

The dimension that's not controlled by the beeswarm placement algorithm we can take directly from the input data. For the "categories" or group placement values, we simply determine the differences between the unique sorted values and increase the width at the sides by half the minimum distance. That means, we create equal space for all categories. If the beeswarm doesn't fit that, again, other parameters have to be adjusted anway.

julia
function Makie.data_limits(bs::Beeswarm)
    points = bs.converted[1][]
    categories = sort(unique(p[1] for p in points))
    range_1 = if length(categories) == 1
        (only(categories) - 0.5, only(categories) + 0.5)
    else
        mindiff = if isnothing(bs.gutter[])
            minimum(diff(categories))
        else
            bs.gutter[]
        end
        (first(categories) - mindiff/2, last(categories) + mindiff/2)
    end
    range_2 = extrema(p[2] for p in points)
    bb = if bs.direction[] === :y
        BBox(range_1..., range_2...)
    elseif bs.direction[] === :x
        BBox(range_2..., range_1...)
    else
        error("Invalid direction $(repr(bs.direction[])), expected :x or :y")
    end
    return Rect3f(bb)
end

Makie.boundingbox(s::Beeswarm, space::Symbol = :data) = Makie.apply_transform_and_model(s, Makie.data_limits(s))

function Makie.plot!(plot::Beeswarm)
    positions = plot.converted[1] # being PointBased, it should always receive a vector of Point2
    @assert positions[] isa AbstractVector{<: Point2} "`positions` should be an `AbstractVector` of `Point2` after conversion, got type $(typeof(positions)).  If you have passed in `x, y, z` input, be aware that `beeswarm` only accepts 2-D input (`x, y`)."

this is a bit risky but #YOLO we extract the plot's parent Scene, from which we can extract the viewport, i.e., pixelspace!

julia
    scene = Makie.parent_scene(plot)

Now, we can extract the Scene's limits from the camera's projectionview. Note that this only works for 2-D Scenes, and gets us the transformed space limits, so if you're trying to run this in a scene with a transform_func, that's something to be aware of.

julia
    final_widths = lift(scene.camera.projectionview) do pv
        isnothing(pv) && return Vec2f(0)
        xmin, xmax = minmax((((-1, 1) .- pv[1, 4]) ./ pv[1, 1])...)
        ymin, ymax = minmax((((-1, 1) .- pv[2, 4]) ./ pv[2, 2])...)
        return Makie.Vec2f(xmax - xmin, ymax - ymin)
    end

and its viewport (in case the scene changes size)

julia
    pixel_widths = @lift widths($(scene.viewport))
    old_pixel_widths = Ref(pixel_widths[])
    old_finalwidths = Ref(final_widths[])

    should_update_based_on_zoom = Observable{Bool}(true)
    onany(plot, final_widths, pixel_widths) do fw, pw # if we change more than 5%, recalculate.
        if !all(isapprox.(fw, old_finalwidths[]; rtol = 0.1)) || !all(isapprox.(pw, old_pixel_widths[]; rtol = 0.05))
            old_pixel_widths[] = pw
            old_finalwidths[] = fw
            notify(should_update_based_on_zoom)
        end
    end
    notify(final_widths)

set up buffers

julia
    point_buffer = Observable{Vector{Point2f}}(zeros(Point2f, length(positions[])))
    pixelspace_point_buffer = Observable{Vector{Point2f}}(zeros(Point2f, length(positions[])))

when the positions change, we must update the buffer arrays

julia
    onany(plot, plot.converted[1], plot.algorithm, plot.transformation.transform_func, plot.markersize, plot.side, plot.direction, plot.gutter, plot.gutter_threshold, should_update_based_on_zoom) do positions, algorithm, tfunc, markersize, side, direction, gutter, gutter_threshold, _
        @assert side in (:both, :left, :right) "side should be one of :both, :left, or :right, got $(side)"
        @assert direction in (:x, :y) "direction should be one of :x or :y, got $(direction)"
        if length(positions) != length(point_buffer[])

recreate the point buffers if lengths have changed

julia
            point_buffer.val = copy(positions)
            pixelspace_point_buffer.val = zeros(Point2f, length(positions))
        end

Apply nonlinear transform function if any

julia
        pixelspace_point_buffer.val .= Makie.apply_transform(tfunc, positions, :data)

Project input positions from data space to pixel space

julia
        pixelspace_point_buffer.val .= Point2f.(Makie.project.((scene.camera,), :data, :pixel, direction == :y ? pixelspace_point_buffer.val : reverse.(pixelspace_point_buffer.val)))

Calculate the beeswarm in pixel space and store it in point_buffer.val

julia
        calculate!(point_buffer.val, algorithm, direction == :y ? pixelspace_point_buffer.val : reverse.(pixelspace_point_buffer.val), markersize, side)

Project the beeswarm back to data space and store it, again, in point_buffer.val

julia
        point_buffer.val .= Point2f.(Makie.project.((scene.camera,), :pixel, :data, direction == :y ? (point_buffer.val) : reverse.(point_buffer.val)))

Finally, apply the inverse transform to move back into data space. TODO: remove this once we have space==:transformed in Makie.

julia
        point_buffer.val .= Makie.apply_transform(Makie.inverse_transform(tfunc), point_buffer.val, :data)

Method to create a gutter when a gutter is defined NOTE: Maybe turn this into a helper function?

julia
        if !isnothing(gutter)
            gutterize!(point_buffer, algorithm, positions, direction, gutter, gutter_threshold)
        end

Finally, update the scatter plot

julia
        notify(point_buffer)
    end

create a set of Attributes that we can pass down

julia
    attrs = copy(plot.attributes)
    pop!(attrs, :algorithm)
    pop!(attrs, :side)
    pop!(attrs, :direction)
    pop!(attrs, :gutter)
    pop!(attrs, :gutter_threshold)

pop!(attrs, :space)

julia
    attrs[:space] = :data
    attrs[:markerspace] = :pixel

create the scatter plot

julia
    scatter_plot = scatter!(
        plot,
        attrs,
        point_buffer
    )
    notify(should_update_based_on_zoom)
    return
end

This function implements "gutters", or regions around each category where points are not allowed to go.

julia
function gutterize!(point_buffer, algorithm::BeeswarmAlgorithm, positions, direction, gutter, gutter_threshold)

This gets the x coordinate of all points

julia
    xs = first.(positions)

Find all points belonging to all unique categories by finding the unique x values

julia
    idx = 1
    for group in unique(xs)

Starting index for the group

julia
        group_indices = findall(==(group), xs)

Calculate a gutter threshold

julia
        gutter_threshold_count = length(group_indices) * gutter_threshold
        gutter_pts = 0
        for idx in group_indices
            pt = point_buffer.val[idx]
            x = direction == :y ? pt[1] : pt[2]

Check if a point values between a acceptable range

julia
            if x < (group - gutter)

Left side of the gutter

julia
                point_buffer.val[idx] = direction == :y ? Point2f(group - gutter, pt[2]) : Point2f(pt[1], group - gutter)
                gutter_pts += 1
            elseif x > (group + gutter)

Right side of the gutter

julia
                point_buffer.val[idx] = direction == :y ? Point2f(group + gutter, pt[2]) : Point2f(pt[1], group + gutter)
                gutter_pts += 1
            end
            idx += 1
        end

Emit warning if too many points fall into the gutter

julia
        if gutter_threshold_count < gutter_pts
            @warn """
            Gutter threshold exceeded for category $(group).
            $(round(gutter_pts/length(group_indices), digits = 2))% of points were placed in the gutter.
            Consider adjusting the `markersize` for the plot to shrink markers, or the gutter size by `gutter`.
            """
        end
    end
end

This page was generated using Literate.jl.