Skip to content

Commit

Permalink
Generic dodging mechanism (#558)
Browse files Browse the repository at this point in the history
Fixes #393 

This PR adds the keywords `xdodge` and `ydodge` to `mapping` which, like
other hardcoded mappings (`layout`, `row`, `col`, `group`) are not tied
to any Makie attributes in particular. Instead, these modify any `AesX`
or `AesY` columns, respectively, when they are used. This allows generic
dodging of things like scatters or errorbars:

```julia
df = (
    x = [1, 1, 2, 2],
    y = 1:4,
    err = [0.5, 0.4, 0.7, 0.6],
    group = ["A", "B", "A", "B"],
)

data(df) *
    (
        mapping(:x, :y, :err, xdodge = :group, color = :group) * visual(Errorbars) +
        mapping(:x, :y, xdodge = :group, color = :group) * visual(Scatter)
    ) |> draw(scales(DodgeX = (; width = 0.2)))
```

<img width="442" alt="image"
src="https://github.com/user-attachments/assets/1cbef74d-2f9c-477e-9dfa-163d4b256b8b">


The width of the dodging can be controlled with a scale attribute:

```julia
mapping(
    repeat(string.('A' .+ (0:9)), inner = 20),
    randn(200),
    xdodge = repeat(["X", "Y"], 100),
    strokecolor = repeat(["X", "Y"], 100),
) * visual(Scatter, strokewidth = 2, color = :transparent) |>
    draw(scales(DodgeX = (; width = 0.5)))
```

<img width="536" alt="image"
src="https://github.com/user-attachments/assets/8285fb43-75aa-4285-a79d-4e835ab2d9ba">

# Caveats / remaining design problems

The most common scenario is errorbars on barplots, I think. However,
barplots implement their own dodging via the `dodge` keyword. (There are
a couple recipes that have `dodge` implemented, rainclouds, crossbars,
violins...) And the width does not depend on the dodging scale but on
the plot's own `width` setting as well as `gap` and `dodge_gap`. So one
can have mismatched dodging:

```julia
df = (
    x = [1, 1, 2, 2],
    y = 1:4,
    err = [0.5, 0.4, 0.7, 0.6],
    group = ["A", "B", "A", "B"],
)

data(df) *
    (
        mapping(:x, :y, dodge = :group, color = :group) * visual(BarPlot) +
        mapping(:x, :y, :err, xdodge = :group) * visual(Errorbars)
    ) |> draw(scales(DodgeX = (; width = 0.2)))
```

<img width="539" alt="image"
src="https://github.com/user-attachments/assets/fbd3930f-6c12-43e4-a82f-84a232e71ab9">

One could use `xdodge` for barplot as well, but then this can conflict
with the `width` setting from barplot, which would then not be adjusted
for anymore (barplot wouldn't know that dodging is going on)

```julia
df = (
    x = [1, 1, 2, 2],
    y = 1:4,
    err = [0.5, 0.4, 0.7, 0.6],
    group = ["A", "B", "A", "B"],
)

data(df) *
    (
        mapping(:x, :y, xdodge = :group, color = :group) * visual(BarPlot, width = 1) +
        mapping(:x, :y, :err, xdodge = :group) * visual(Errorbars)
    ) |> draw(scales(DodgeX = (; width = 0.2)))
```

<img width="544" alt="image"
src="https://github.com/user-attachments/assets/f3687dd3-5a37-4351-845d-28f4f3dd307d">

Note that in this particular case, if `width = 1` wasn't set, the plot
would look ok, but that might not always be the case if enough
categories are missing, so it's too brittle I think.

<img width="542" alt="image"
src="https://github.com/user-attachments/assets/99f27dbd-6b25-4c42-975d-213c910c8bd6">

So ideally, I think the `width` for widthless plots (scatter, errorbars,
etc.) could be inferred from plots with width that are also using the
dodge scale. So if a barplot is in the mix, the width would be picked up
from it somehow. This adds a small layer of complexity on top of the
processing pipeline but might be manageable.
  • Loading branch information
jkrumbiegel authored Sep 17, 2024
1 parent 7e316ba commit 2ab0756
Show file tree
Hide file tree
Showing 14 changed files with 356 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Fixed aesthetics of `errorbar` so that x and y stay labelled correctly when using `direction = :x` [#560](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/560).
- Added ability to specify `title`, `subtitle` and `footnotes` plus settings in the `draw` function [#556](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/556).
- Added `dodge_x` and `dodge_y` keywords to `mapping` that allow to dodge any plot types that have `AesX` or `AesY` data [#558](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/558).

## v0.8.7 - 2024-09-06

Expand Down
1 change: 1 addition & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ authors = ["Pietro Vertechi", "Julius Krumbiegel"]
version = "0.8.7"

[deps]
Accessors = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697"
Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
Dictionaries = "85a47980-9c8c-11e8-2b9f-f7ca1fa99fb4"
Expand Down
2 changes: 1 addition & 1 deletion docs/gallery/gallery/scales/config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"description": "Some advanced keywords to tweak the plot",
"order": ["discrete_scales.jl", "continuous_scales.jl", "custom_scales.jl", "secondary_scales.jl", "multiple_color_scales.jl", "prescaled_data.jl", "legend_merging.jl"]
"order": ["discrete_scales.jl", "continuous_scales.jl", "custom_scales.jl", "secondary_scales.jl", "multiple_color_scales.jl", "prescaled_data.jl", "legend_merging.jl", "dodging.jl"]
}
58 changes: 58 additions & 0 deletions docs/gallery/gallery/scales/dodging.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ---
# title: Dodging
# cover: assets/dodging.png
# description: Dodging groups to avoid overlaps.
# author: "Julius Krumbiegel"
# id: dodging
# ---

using AlgebraOfGraphics, CairoMakie
using Colors
set_aog_theme!() #src

# Some plot types like barplots natively support a `dodge` attribute which avoids overlap between
# groups that share the same coordinates.

df = (; x = ["One", "One", "Two", "Two"], y = 1:4, err = [0.2, 0.3, 0.4, 0.5], group = ["A", "B", "A", "B"])
plt = data(df) * mapping(:x, :y, dodge = :group, color = :group) * visual(BarPlot)
draw(plt)

# Other plot types like errorbars do not have a `dodge` keyword, however you can dodge them using
# AlgebraOfGraphic's hardcoded `dodge_x` or `dodge_y` mappings.
# These will only shift the data away from the category centers but will not change
# other plot attributes (like dodging a barplot makes narrower bars).
# They are therefore mostly appropriate for "width-less" plot types like scatters or errorbars.
#
# If you combine errorbars with a barplot, AlgebraOfGraphics will apply the barplot's dodge width
# to the errorbars automatically so they match:

plt2 = data(df) * mapping(:x, :y, :err, dodge_x = :group) * visual(Errorbars)
fg = draw(plt + plt2)

# If you only use "width-less" plot types, you will get an error if you don't set a dodge width manually.
# You can do so via the `scales` function:

df = (
x = repeat(1:10, inner = 2),
y = cos.(range(0, 2pi, length = 20)),
ylow = cos.(range(0, 2pi, length = 20)) .- 0.2,
yhigh = cos.(range(0, 2pi, length = 20)) .+ 0.3,
dodge = repeat(["A", "B"], 10)
)

f = Figure()
plt3 = data(df) * (
mapping(:x, :y, dodge_x = :dodge, color = :dodge) * visual(Scatter) +
mapping(:x, :ylow, :yhigh, dodge_x = :dodge, color = :dodge) * visual(Rangebars)
)
kw(; kwargs...) = (; xticklabelsvisible = false, xticksvisible = false, xlabelvisible = false, kwargs...)

draw!(f[1, 1], plt3, scales(DodgeX = (; width = 0.25)); axis = kw(title = "DodgeX = (; width = 0.25)"))
draw!(f[2, 1], plt3, scales(DodgeX = (; width = 0.5)); axis = kw(title = "DodgeX = (; width = 0.5)"))
draw!(f[3, 1], plt3, scales(DodgeX = (; width = 1.0)); axis = (; title = "DodgeX = (; width = 1.0)"))

f

# save cover image #src
mkpath("assets") #src
save("assets/dodging.png", fg) #src
68 changes: 68 additions & 0 deletions docs/src/layers/mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,74 @@ draw!(f[1, 2], spec2)
f
```

## Hardcoded aesthetics

Most aesthetics are tied to specific attributes of plot types, for example like `AesColor` to `strokecolor` of `Scatter`.
There are a few aesthetics, however, which are hardcoded to belong to certain `mapping` keywords independent of the plot type in use.

These are `layout`, `row` and `col` for facetting, `group` for creating a separate plot for each group (like separate lines instead of one long line) and `dodge_x` and `dodge_y` for dodging.

### Dodging

Dodging refers to the shifting of plots on a (usually categorical) scale depending on the group they belong to.
It is used to avoid overlaps. Some plot types, like `BarPlot`, have their own `dodge` keyword because their dodging logic additionally needs to transform the visual elements (for example, dodging a bar plot makes thinner bars). For all other plot types, you can use the generic `dodge_x` and `dodge_y` keywords.

They work by shifting each categorical group by some value that depends on the chosen "dodge width".
The dodge width refers to the width that all dodged elements in a group add up to at a given point.
Some plot types have an inherent width, like barplots. Others have no width, like scatters or errorbars.
For those plot types that have no width to use for dodging, you have to specify one manually in `scales`.

Here's an example of a manual width selection:

```@example
using AlgebraOfGraphics
using CairoMakie
df = (
x = repeat(1:10, inner = 2),
y = cos.(range(0, 2pi, length = 20)),
ylow = cos.(range(0, 2pi, length = 20)) .- 0.2,
yhigh = cos.(range(0, 2pi, length = 20)) .+ 0.3,
dodge = repeat(["A", "B"], 10)
)
f = Figure()
plt = data(df) * (
mapping(:x, :y, dodge_x = :dodge, color = :dodge) * visual(Scatter) +
mapping(:x, :ylow, :yhigh, dodge_x = :dodge, color = :dodge) * visual(Rangebars)
)
draw!(f[1, 1], plt, scales(DodgeX = (; width = 1)), axis = (; title = "width = 1"))
draw!(f[1, 2], plt, scales(DodgeX = (; width = 0.75)), axis = (; title = "width = 0.75"))
draw!(f[2, 1], plt, scales(DodgeX = (; width = 0.5)), axis = (; title = "width = 0.5"))
draw!(f[2, 2], plt, scales(DodgeX = (; width = 0.25)), axis = (; title = "width = 0.25"))
f
```

A common scenario is plotting errorbars on top of barplots.
In this case, AlgebraOfGraphics can detect the inherent dodging width of the barplots and adjust accordingly for the errorbars. Note in this example how choosing a manual dodging width only applies to the errorbars (because the barplot plot type handles this internally) and potentially leads to a misalignment between the different plot elements:

```@example
using AlgebraOfGraphics
using CairoMakie
df = (
x = repeat(1:10, inner = 2),
y = cos.(range(0, 2pi, length = 20)),
ylow = cos.(range(0, 2pi, length = 20)) .- 0.2,
yhigh = cos.(range(0, 2pi, length = 20)) .+ 0.3,
dodge = repeat(["A", "B"], 10)
)
f = Figure()
plt = data(df) * (
mapping(:x, :y, dodge = :dodge, color = :dodge) * visual(BarPlot) +
mapping(:x, :ylow, :yhigh, dodge_x = :dodge) * visual(Rangebars)
)
draw!(f[1, 1], plt, axis = (; title = "No width specified, auto-determined by AlgebraOfGraphics"))
draw!(f[2, 1], plt, scales(DodgeX = (; width = 0.25)), axis = (; title = "Manually specifying width = 0.25 leads to a mismatch"))
f
```

## Pair syntax

The `Pair` operator `=>` can be used for three different purposes within `mapping`:
Expand Down
1 change: 1 addition & 0 deletions src/AlgebraOfGraphics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ using Dictionaries: AbstractDictionary, Dictionary, Indices, getindices, set!, d
using KernelDensity: kde, pdf
using StatsBase: fit, histrange, Histogram, normalize, sturges, StatsBase

import Accessors
import GLM, Loess
import FileIO
import RelocatableFolders
Expand Down
22 changes: 17 additions & 5 deletions src/aesthetics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function aesthetic_mapping(::Type{BarPlot}, N::Int)
1 => :direction => dictionary([
:y => AesY,
:x => AesX,
])
]),
]
else
[
Expand All @@ -105,7 +105,10 @@ function aesthetic_mapping(::Type{BarPlot}, N::Int)
:y => AesDeltaX,
:x => AesDeltaY,
]),
:dodge => AesDodge,
:dodge => :direction => dictionary([
:y => AesDodgeX,
:x => AesDodgeY,
]),
:stack => AesStack,
:fillto => :direction => dictionary([
:y => AesY,
Expand All @@ -127,7 +130,10 @@ function aesthetic_mapping(::Type{Violin}, ::Normal, ::Normal)
]),
:color => AesColor,
:side => AesViolinSide,
:dodge => AesDodge,
:dodge => :orientation => dictionary([
:horizontal => AesDodgeY,
:vertical => AesDodgeX,
]),
])
end

Expand Down Expand Up @@ -284,7 +290,10 @@ function aesthetic_mapping(::Type{BoxPlot}, ::Normal, ::Normal)
:vertical => AesY,
]),
:color => AesColor,
:dodge => AesDodge,
:dodge => :orientation => dictionary([
:horizontal => AesDodgeY,
:vertical => AesDodgeX,
]),
])
end

Expand All @@ -307,7 +316,10 @@ function aesthetic_mapping(::Type{CrossBar}, ::Normal, ::Normal, ::Normal, ::Nor
:vertical => AesY,
]),
:color => AesColor,
:dodge => AesDodge,
:dodge => :orientation => dictionary([
:horizontal => AesDodgeY,
:vertical => AesDodgeX,
]),
])
end

Expand Down
3 changes: 2 additions & 1 deletion src/algebra/layer.jl
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ _default_categorical_palette(::Type{AesLineStyle}) = to_value(Makie.current_defa
_default_categorical_palette(::Type{AesLayout}) = wrap
_default_categorical_palette(::Type{<:Union{AesRow,AesCol}}) = Makie.automatic
_default_categorical_palette(::Type{AesGroup}) = Makie.automatic
_default_categorical_palette(::Type{AesDodge}) = Makie.automatic
_default_categorical_palette(::Type{AesDodgeX}) = Makie.automatic
_default_categorical_palette(::Type{AesDodgeY}) = Makie.automatic
_default_categorical_palette(::Type{AesStack}) = Makie.automatic
_default_categorical_palette(::Type{AesViolinSide}) = [:left, :right]

Expand Down
Loading

0 comments on commit 2ab0756

Please sign in to comment.