From fce9f635c0b9efecdb017c53c1f067fbba09fa1b Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Thu, 3 Oct 2024 17:16:48 +0200 Subject: [PATCH 1/6] give beeswarm plots fixed limits given input data --- src/recipe.jl | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/recipe.jl b/src/recipe.jl index c5ecb19..dbf3d87 100644 --- a/src/recipe.jl +++ b/src/recipe.jl @@ -60,7 +60,42 @@ function calculate!(buffer::AbstractVector{<: Point2}, alg::NoBeeswarm, position return end -Makie.data_limits(bs::Beeswarm) = Makie.data_limits(bs.plots[1]) +# 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. +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 = minimum(diff(categories)) + (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 From 7c3b832909f4a96cc0157dfac47441b268a082c0 Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel <22495855+jkrumbiegel@users.noreply.github.com> Date: Thu, 3 Oct 2024 17:33:40 +0200 Subject: [PATCH 2/6] Update src/recipe.jl Co-authored-by: Anshul Singhvi --- src/recipe.jl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/recipe.jl b/src/recipe.jl index dbf3d87..09b2c6e 100644 --- a/src/recipe.jl +++ b/src/recipe.jl @@ -81,7 +81,11 @@ function Makie.data_limits(bs::Beeswarm) range_1 = if length(categories) == 1 (only(categories) - 0.5, only(categories) + 0.5) else - mindiff = minimum(diff(categories)) + 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) From b907141a4c1f52e59586bade089f54a121acb467 Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Thu, 3 Oct 2024 18:01:27 +0200 Subject: [PATCH 3/6] make gutters in test small enough to warn again --- test/runtests.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index 5cb9247..99e813c 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -54,12 +54,12 @@ buffer = deepcopy(pixel_points) f, a, p = beeswarm(rand(1:3, 300), randn(300); color = rand(RGBAf, 300), markersize = 20, algorithm = SimpleBeeswarm()) Makie.update_state_before_display!(f) Makie.update_state_before_display!(f) - @test_warn "Gutter threshold exceeded" p.gutter = 0.5 + @test_warn "Gutter threshold exceeded" p.gutter = 0.2 # Next, we test it in direction y f, a, p = beeswarm(rand(1:3, 300), randn(300); direction = :x, color = rand(RGBAf, 300), markersize = 20, algorithm = SimpleBeeswarm()) Makie.update_state_before_display!(f) Makie.update_state_before_display!(f) - @test_warn "Gutter threshold exceeded" p.gutter = 0.5 + @test_warn "Gutter threshold exceeded" p.gutter = 0.2 # and it shouldn't warn if, when using a lower markersize, the gutter is not reached. f, a, p = beeswarm(rand(1:3, 300), randn(300); direction = :y, color = rand(RGBAf, 300), markersize = 9, algorithm = SimpleBeeswarm()) Makie.update_state_before_display!(f) From 3e4452ca6f03039056c229e18a6653165f0815fb Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Thu, 3 Oct 2024 18:04:45 +0200 Subject: [PATCH 4/6] remove unnecessary figure state updates --- docs/src/examples/examples.jl | 7 ------- test/runtests.jl | 9 ++------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/docs/src/examples/examples.jl b/docs/src/examples/examples.jl index bf63281..3b2a18c 100644 --- a/docs/src/examples/examples.jl +++ b/docs/src/examples/examples.jl @@ -22,9 +22,6 @@ using PalmerPenguins, DataFrames penguins = dropmissing(DataFrame(PalmerPenguins.load())) f = data(penguins) * mapping(:species, :bill_depth_mm, color=:sex) * visual(Beeswarm) |> draw -Makie.update_state_before_display!(f.figure) -Makie.update_state_before_display!(f.figure) -Makie.update_state_before_display!(f.figure) f # ## SwarmMakie logo @@ -43,10 +40,6 @@ f.scene.backgroundcolor[] = RGBAf(1,1,1,0) a.scene.backgroundcolor[] = RGBAf(1,1,1,0) hidedecorations!(a) hidespines!(a) -Makie.update_state_before_display!(f) -Makie.update_state_before_display!(f) -Makie.update_state_before_display!(f) -Makie.update_state_before_display!(f) f # ## Wilkinson's dot histogram diff --git a/test/runtests.jl b/test/runtests.jl index 99e813c..4fb8410 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -28,8 +28,6 @@ buffer = deepcopy(pixel_points) hidedecorations!(a) hidespines!(a) Makie.update_state_before_display!(f) - Makie.update_state_before_display!(f) - Makie.update_state_before_display!(f) img = Makie.colorbuffer(f.scene; px_per_unit = 1, pt_per_unit = 1, antialias = :none, visible = true, start_renderloop = false) # We have a matrix of all colors in the image. Now, what we do is the following: # The color white in RGBf is (1, 1, 1). For a color to be red, the blue and green components @@ -53,16 +51,13 @@ buffer = deepcopy(pixel_points) # First, we test the regular gutter with multiple categories. f, a, p = beeswarm(rand(1:3, 300), randn(300); color = rand(RGBAf, 300), markersize = 20, algorithm = SimpleBeeswarm()) Makie.update_state_before_display!(f) - Makie.update_state_before_display!(f) @test_warn "Gutter threshold exceeded" p.gutter = 0.2 # Next, we test it in direction y - f, a, p = beeswarm(rand(1:3, 300), randn(300); direction = :x, color = rand(RGBAf, 300), markersize = 20, algorithm = SimpleBeeswarm()) - Makie.update_state_before_display!(f) + f, a, p = beeswarm(rand(1:3, 300), randn(300); direction = :x, color = rand(RGBAf, 300), markersize = 20, algorithm = SimpleBeeswarm()) Makie.update_state_before_display!(f) @test_warn "Gutter threshold exceeded" p.gutter = 0.2 # and it shouldn't warn if, when using a lower markersize, the gutter is not reached. - f, a, p = beeswarm(rand(1:3, 300), randn(300); direction = :y, color = rand(RGBAf, 300), markersize = 9, algorithm = SimpleBeeswarm()) - Makie.update_state_before_display!(f) + f, a, p = beeswarm(rand(1:3, 300), randn(300); direction = :y, color = rand(RGBAf, 300), markersize = 9, algorithm = SimpleBeeswarm()) Makie.update_state_before_display!(f) @test_nowarn p.gutter = 0.5 end From 69f34cfea52784c6eb0748187886035270a3d1f0 Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Thu, 3 Oct 2024 18:06:07 +0200 Subject: [PATCH 5/6] remove even more state updates and note to do so --- docs/src/algorithms.md | 1 - docs/src/introduction.md | 3 --- 2 files changed, 4 deletions(-) diff --git a/docs/src/algorithms.md b/docs/src/algorithms.md index 5c8ab53..f49ad0e 100644 --- a/docs/src/algorithms.md +++ b/docs/src/algorithms.md @@ -19,7 +19,6 @@ ax_plots = [beeswarm(fig[Tuple(idx)...], xs, ys; color = xs, algorithm = algorit jitter_plots = getproperty.(ax_plots[2, :], :plot) setproperty!.(jitter_plots, :markersize, 7) setproperty!.(jitter_plots, :alpha, 0.3) -Makie.update_state_before_display!(fig) fig ``` diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 10dab2d..cf7b8b4 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -35,8 +35,6 @@ iris = dataset("datasets", "iris") f = data(iris) * mapping(:Species, :SepalLength; color = :Species) * visual(Beeswarm) |> draw -Makie.update_state_before_display!(f.figure) -Makie.update_state_before_display!(f.figure) f ``` @@ -44,4 +42,3 @@ f If your beeswarms are overlapping, or extending outside the axis area, try decreasing `markersize`. You can do this by setting `plot.markersize = 6` for example, and then re-displaying the figure. -Generally, the algorithm takes a few iterations of calling `Makie.update_state_before_display!(figure)` to settle in a good configuration. We are working to fix this. From a41aa317869aa6530bd879747f9add963332294e Mon Sep 17 00:00:00 2001 From: Julius Krumbiegel Date: Thu, 3 Oct 2024 23:28:12 +0200 Subject: [PATCH 6/6] add new beeswarm algorithm --- src/SwarmMakie.jl | 2 + src/algorithms/simple2.jl | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/algorithms/simple2.jl diff --git a/src/SwarmMakie.jl b/src/SwarmMakie.jl index ef57751..321e932 100644 --- a/src/SwarmMakie.jl +++ b/src/SwarmMakie.jl @@ -5,6 +5,7 @@ module SwarmMakie using Makie using Random import StatsBase: + mean, Histogram, fit, UnitWeights @@ -16,5 +17,6 @@ include("algorithms/simple.jl") include("algorithms/seaborn.jl") include("algorithms/wilkinson.jl") include("algorithms/jitter.jl") +include("algorithms/simple2.jl") end diff --git a/src/algorithms/simple2.jl b/src/algorithms/simple2.jl new file mode 100644 index 0000000..8671366 --- /dev/null +++ b/src/algorithms/simple2.jl @@ -0,0 +1,127 @@ +struct SimpleBeeswarm2 <: BeeswarmAlgorithm +end + + +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] + if isempty(ys_sorted) + continue + else + xs[group] .= simple_beeswarm2(ys_sorted, markersize)[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 + empty!(blocked_x_intervals) + backwards_iter = (i-1):-1:1 + # go backwards through all markers that are overlapping in y + 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 + 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 + sort!(blocked_x_intervals) + + # this marker can be placed at zero as it doesn't overlap any of the previous ones in y + 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 + 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 + 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 + 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 + 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 + 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) + 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 \ No newline at end of file