Skip to content

Simple beeswarm 2 (default)

This algorithm was contributed by Julius Krumbiegel (@jkrumbiegel) in PR #29.

julia
"""
    SimpleBeeswarm2()

A simple beeswarm implementation, that minimizes overlaps. This is the
default algorithm used in `beeswarm`.

This algorithm dodges in `x` but preserves the exact `y` coordinate of each point.
If you don't want to preserve the y coordinate, check out `WilkinsonBeeswarm`.
"""
struct SimpleBeeswarm2 <: BeeswarmAlgorithm
end

export SimpleBeeswarm2

function calculate!(buffer::AbstractVector{<: Point2}, alg::SimpleBeeswarm2, positions::AbstractVector{<: Point2}, markersize, side::Symbol)
    ys = last.(positions)
    xs = first.(positions)

    for x_val in unique(xs)
        group = findall(==(x_val), xs)
        view_ys = view(ys, group)
        perm = sortperm(view_ys)
        ys_sorted = view_ys[perm]

        ms = if markersize isa Number
            markersize
        else
            if length(unique(markersize[group])) == 1
                markersize[group][1]
            else # this should error
                markersize[group]
            end
        end

        if isempty(ys_sorted)
            continue
        else
            xs[group] .= simple_beeswarm2(ys_sorted, ms)[invperm(perm)]
        end
    end

    buffer .= Point2f.(xs .+ first.(positions), last.(positions))
end

absmin(a, b) = abs(a) < abs(b) ? a : b

covers_zero(a, b) = a <= 0 && b >= 0

function simple_beeswarm2(sorted_ys, markersize)
    @assert issorted(sorted_ys)
    xs = zeros(length(sorted_ys))

    ms_squared = markersize ^ 2

    blocked_x_intervals = Tuple{Float64,Float64}[]

    for i in eachindex(sorted_ys)
        y = sorted_ys[i]

store all intervals that the y-adjacent markers are blocking

julia
        empty!(blocked_x_intervals)
        backwards_iter = (i-1):-1:1

go backwards through all markers that are overlapping in y

julia
        for j in backwards_iter
            delta_y = y - sorted_ys[j]
            delta_y > markersize && break

compute the x distance between two circles that touch, each is markersize in diameter and they are delta_y apart vertically, the current marker would have to be at least that distance away in positive or negative direction

julia
            blocked_delta_x = sqrt(ms_squared - delta_y ^ 2)
            blocked_x = xs[j]
            blocked_x_interval = (blocked_x - blocked_delta_x, blocked_x + blocked_delta_x)
            push!(blocked_x_intervals, blocked_x_interval)
        end

we want to go through all blocked intervals from left to right and see if there's a gap between them (a point gap is enough as we've already included the new marker's size in these blocked intervals) the point where we'll place the new marker is either right at the edge of one of these intervals, or it's at zero

julia
        sort!(blocked_x_intervals)

this marker can be placed at zero as it doesn't overlap any of the previous ones in y

julia
        isempty(blocked_x_intervals) && continue

        if length(blocked_x_intervals) == 1

if there's just one blocked interval, we immediately know what the best value is and the loop below doesn't apply because it starts at 2

julia
            only_interval = blocked_x_intervals[]
            if covers_zero(only_interval...)
                x_closest_to_zero = absmin(blocked_x_intervals[]...)
            else
                x_closest_to_zero = 0.0
            end
        else

otherwise our first candidate is the left edge of the first interval

julia
            x_closest_to_zero = first(blocked_x_intervals)[1]
        end

        left_interval = first(blocked_x_intervals)
        for j in 2:length(blocked_x_intervals)
            right_interval = blocked_x_intervals[j]

            if right_interval[1] < left_interval[2]

if two adjacent intervals overlap, we merge them together for the next loop iteration which we directly go to

julia
                left_interval = (left_interval[1], max(left_interval[2], right_interval[2]))

but if we're at the last interval, we will not see another loop iteration, so we have to check the last interval for a candidate directly

julia
                if j == length(blocked_x_intervals) && abs(left_interval[2]) < abs(x_closest_to_zero)
                    x_closest_to_zero = left_interval[2]
                end
                continue
            else
                new_candidate_x = if covers_zero(left_interval[2], right_interval[1])
                    0.0
                elseif abs(left_interval[2]) < abs(right_interval[1])
                    left_interval[2]
                else
                    right_interval[1]
                end
                if abs(new_candidate_x) < abs(x_closest_to_zero)
                    x_closest_to_zero = new_candidate_x
                    left_interval = right_interval
                else

we can't get closer once our candidates start getting worse than the best one we have so far, which means we're going further away from zero to the right

julia
                    break
                end
            end
        end
        xs[i] = x_closest_to_zero
    end

for better centering, we zero out groups of markers by their mean where each group is separated from its neighbors by a gap of at least one markersize (which means they can't collide when moving horizontally)

julia
    align_group_start = 1
    for i in eachindex(sorted_ys) .+ 1
        if (i - 1) == length(sorted_ys) || sorted_ys[i] - sorted_ys[i-1] >= markersize
            xs[align_group_start:i-1] .-= mean(@view(xs[align_group_start:i-1]))
            align_group_start = i
        end
    end

    return xs
end

This page was generated using Literate.jl.