Beeswarm recipe
export beeswarm, beeswarm!
export NoBeeswarmIn this file, we define the Beeswarm recipe.
"""
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
abstract type BeeswarmAlgorithm endThis is mostly useful to test the recipe...
"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
endBeeswarm 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.
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!
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.
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)
endand its viewport (in case the scene changes size)
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
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
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
point_buffer.val = copy(positions)
pixelspace_point_buffer.val = zeros(Point2f, length(positions))
endApply nonlinear transform function if any
pixelspace_point_buffer.val .= Makie.apply_transform(tfunc, positions, :data)Project input positions from data space to pixel space
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
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
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.
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?
if !isnothing(gutter)
gutterize!(point_buffer, algorithm, positions, direction, gutter, gutter_threshold)
endFinally, update the scatter plot
notify(point_buffer)
endcreate a set of Attributes that we can pass down
attrs = copy(plot.attributes)
pop!(attrs, :algorithm)
pop!(attrs, :side)
pop!(attrs, :direction)
pop!(attrs, :gutter)
pop!(attrs, :gutter_threshold)pop!(attrs, :space)
attrs[:space] = :data
attrs[:markerspace] = :pixelcreate the scatter plot
scatter_plot = scatter!(
plot,
attrs,
point_buffer
)
notify(should_update_based_on_zoom)
return
endThis function implements "gutters", or regions around each category where points are not allowed to go.
function gutterize!(point_buffer, algorithm::BeeswarmAlgorithm, positions, direction, gutter, gutter_threshold)This gets the x coordinate of all points
xs = first.(positions)Find all points belonging to all unique categories by finding the unique x values
idx = 1
for group in unique(xs)Starting index for the group
group_indices = findall(==(group), xs)Calculate a gutter threshold
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
if x < (group - gutter)Left side of the gutter
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
point_buffer.val[idx] = direction == :y ? Point2f(group + gutter, pt[2]) : Point2f(pt[1], group + gutter)
gutter_pts += 1
end
idx += 1
endEmit warning if too many points fall into the gutter
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
endThis page was generated using Literate.jl.