Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manual legend labels #565

Merged
merged 4 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Added ability to include layers in the legend without using scales by adding `visual(label = "some label")` [#565](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/565).

## v0.8.8 - 2024-09-17

- 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).
Expand Down
29 changes: 28 additions & 1 deletion docs/src/generated/visual.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# visual
# ```

## Examples
# ## Examples

using AlgebraOfGraphics, CairoMakie
set_aog_theme!()
Expand All @@ -28,3 +28,30 @@ draw(plt * visual(Contour)) # plot as contour
#

draw(plt * visual(Contour, linewidth=2)) # plot as contour with thicker lines

# ## Manual legend entries via `label`

# The legend normally contains entries for all appropriate scales used in the plot.
# Sometimes, however, you just want to label certain plots such that they appear in the legend without using any scale.
# You can achieve this by adding the `label` keyword to all `visual`s that you want to label.
# Layers with the same `label` will be combined within a legend entry.

x = range(0, 4pi, length = 40)
layer1 = data((; x = x, y = cos.(x))) * mapping(:x, :y) * visual(Lines, linestyle = :dash, label = "A cosine line")
layer2 = data((; x = x, y = sin.(x) .+ 2)) * mapping(:x, :y) *
(visual(Lines, color = (:tomato, 0.4)) + visual(Scatter, color = :tomato)) * visual(label = "A sine line + scatter")
draw(layer1 + layer2)

# If the figure contains other scales, the legend will list the labelled group last by default. If you want to reorder, use the symbol `:Label` to specify the labelled group.

df = (; x = repeat(1:10, 3), y = cos.(1:30), group = repeat(["A", "B", "C"], inner = 10))
spec1 = data(df) * mapping(:x, :y, color = :group) * visual(Lines)

spec2 = data((; x = 1:10, y = cos.(1:10) .+ 2)) * mapping(:x, :y) * visual(Scatter, color = :purple, label = "Scatter")

f = Figure()
fg = draw!(f[1, 1], spec1 + spec2)
legend!(f[1, 2], fg)
legend!(f[1, 3], fg, order = [:Label, :Color])

f
196 changes: 111 additions & 85 deletions src/guides/legend.jl
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,26 @@ function compute_legend(grid::Matrix{AxisEntries}; order::Union{Nothing,Abstract

scales = Iterators.flatten((pairs(scales_categorical), pairs(scales_continuous)))

# if no legendable scale is present, return nothing
isempty(scales) && return nothing
processedlayers = first(grid).processedlayers

# we can't loop over all processedlayers here because one layer can be sliced into multiple processedlayers
unique_processedlayers = unique_by(processedlayers) do pl
(pl.plottype, pl.attributes)
end

# some layers might have been explicitly labelled with `visual(label = "some label")`
# and these need to get their own legend section
labelled_layers = Dictionary{Any,Vector{Any}}()
for pl in unique_processedlayers
haskey(pl.attributes, :label) || continue
label = pl.attributes[:label]
# we stack all processedlayers sharing a label into one legend entry
v = get!(Vector{Any}, labelled_layers, label)
push!(v, pl)
end

# if there are no legendable scales or labelled layers, we don't need a legend
isempty(scales) && isempty(labelled_layers) && return nothing

scales_by_symbol = Dictionary{Symbol,ScaleWithMeta}()

Expand All @@ -113,18 +131,12 @@ function compute_legend(grid::Matrix{AxisEntries}; order::Union{Nothing,Abstract
end
end

processedlayers = first(grid).processedlayers

titles = []
labels = Vector[]
elements_list = Vector{Vector{LegendElement}}[]

# we can't loop over all processedlayers here because one layer can be sliced into multiple processedlayers
unique_processedlayers = unique_by(processedlayers) do pl
(pl.plottype, pl.attributes)
end

final_order = if order === nothing
final_order::Vector{Any} = if order === nothing
basic_order = collect(keys(scales_by_symbol))
merged_order = []
i = 1
Expand All @@ -138,18 +150,21 @@ function compute_legend(grid::Matrix{AxisEntries}; order::Union{Nothing,Abstract
end
if !isempty(mergeable_indices)
push!(merged_order, (sc, basic_order[mergeable_indices]...))
else
else
push!(merged_order, sc)
end
deleteat!(basic_order, mergeable_indices)
i += 1
end
if !isempty(labelled_layers)
push!(merged_order, :Label)
end
merged_order
else
order
end

syms_or_symgroups_and_title(sym::Symbol) = [sym], getlabel(scales_by_symbol[sym].scale)
syms_or_symgroups_and_title(sym::Symbol) = [sym], sym === :Label ? nothing : getlabel(scales_by_symbol[sym].scale)
syms_or_symgroups_and_title(syms::AbstractVector{Symbol}) = syms, nothing
syms_or_symgroups_and_title(syms_title::Pair{<:AbstractVector{Symbol},<:Any}) = syms_title
syms_or_symgroups_and_title(any) = throw(ArgumentError("Invalid legend order element $any"))
Expand All @@ -165,105 +180,116 @@ function compute_legend(grid::Matrix{AxisEntries}; order::Union{Nothing,Abstract
return symgroups, title
end

syms_symgroups_titles = Any[syms_or_symgroups_and_title(el) for el in final_order]

used_scales = Set{Symbol}()

for order_element in final_order
syms_or_symgroups, title = syms_or_symgroups_and_title(order_element)
for (syms_or_symgroups, title) in syms_symgroups_titles
title = title == "" ? nothing : title # empty titles can be hidden completely if they're `nothing`, "" still uses layout space
push!(titles, title)
legend_els = []
datalabs = []
for sym_or_symgroup in syms_or_symgroups
# a symgroup is a vector of scale symbols which all represent the same underlying categorical
# data, so their legends can be merged into one
symgroup::Vector{Symbol} = sym_or_symgroup isa Symbol ? [sym_or_symgroup] : sym_or_symgroup

for sym in symgroup
if sym in used_scales
error("Scale $sym appeared twice in legend order.")
if sym_or_symgroup === :Label
push!(used_scales, :Label)
for (label, processedlayers) in pairs(labelled_layers)
push!(legend_els, mapreduce(vcat, processedlayers) do p
legend_elements(p, MixedArguments())
end)
push!(datalabs, label)
end
push!(used_scales, sym)
end

scalewithmetas = [scales_by_symbol[sym] for sym in symgroup]
aess = [scalewithmeta.aes for scalewithmeta in scalewithmetas]
scale_ids = [scalewithmeta.scale_id for scalewithmeta in scalewithmetas]
_scales = [scalewithmeta.scale for scalewithmeta in scalewithmetas]

dpds = [datavalues_plotvalues_datalabels(aes, scale) for (aes, scale) in zip(aess, _scales)]

# Check that all scales in the merge group are compatible for the legend
# (they should be if we have computed them, but they might not be if they were passed manually)

for (k, kind) in zip([1, 3], ["values", "labels"])
for i in 2:length(symgroup)
if dpds[1][k] != dpds[i][k]
error("""
Got passed scales $(repr(symgroup[1])) and $(repr(symgroup[i])) as a mergeable legend group but their data $kind don't match.
Data $kind for $(repr(symgroup[1])) are $(dpds[1][k])
Data $kind for $(repr(symgroup[i])) are $(dpds[i][k])
"""
)
else
# a symgroup is a vector of scale symbols which all represent the same underlying categorical
# data, so their legends can be merged into one
symgroup::Vector{Symbol} = sym_or_symgroup isa Symbol ? [sym_or_symgroup] : sym_or_symgroup

for sym in symgroup
if sym in used_scales
error("Scale $sym appeared twice in legend order.")
end
push!(used_scales, sym)
end
end

# we can now extract data values and labels from the first entry, knowing they are all the same
_datavals = dpds[1][1]
_datalabs = dpds[1][3]

_legend_els = [LegendElement[] for _ in _datavals]

# We are layering legend elements on top of each other by deriving them from the processed layers,
# each processed layer can contribute a vector of legend elements for each data value in the scale.
for processedlayer in unique_processedlayers
aes_mapping = aesthetic_mapping(processedlayer)

# for each scale in the merge group, we're extracting the keys (of all positional and keyword mappings)
# for which the aesthetic and the scale id match a mapping of the processed layer
# (so basically we're finding all mappings which have used this scale)
all_plotval_kwargs = map(aess, scale_ids, dpds) do aes, scale_id, (_, plotvals, _)
matching_keys = filter(keys(merge(Dictionary(processedlayer.positional), processedlayer.primary, processedlayer.named))) do key
get(aes_mapping, key, nothing) === aes &&
get(processedlayer.scale_mapping, key, nothing) === scale_id
scalewithmetas = [scales_by_symbol[sym] for sym in symgroup]
aess = [scalewithmeta.aes for scalewithmeta in scalewithmetas]
scale_ids = [scalewithmeta.scale_id for scalewithmeta in scalewithmetas]
_scales = [scalewithmeta.scale for scalewithmeta in scalewithmetas]

dpds = [datavalues_plotvalues_datalabels(aes, scale) for (aes, scale) in zip(aess, _scales)]

# Check that all scales in the merge group are compatible for the legend
# (they should be if we have computed them, but they might not be if they were passed manually)

for (k, kind) in zip([1, 3], ["values", "labels"])
for i in 2:length(symgroup)
if dpds[1][k] != dpds[i][k]
error("""
Got passed scales $(repr(symgroup[1])) and $(repr(symgroup[i])) as a mergeable legend group but their data $kind don't match.
Data $kind for $(repr(symgroup[1])) are $(dpds[1][k])
Data $kind for $(repr(symgroup[i])) are $(dpds[i][k])
"""
)
end
end
end

# for each mapping which used the scale, we extract the matching plot value
# for example the processed layer might have used the current `AesColor` scale
# on the `color` mapping keyword, so we store `{:color => red}`, `{:color => blue}`, etc,
# one for each value in the categorical scale
map(plotvals) do plotval
MixedArguments(map(key -> plotval, matching_keys))
# we can now extract data values and labels from the first entry, knowing they are all the same
_datavals = dpds[1][1]
_datalabs = dpds[1][3]

_legend_els = [LegendElement[] for _ in _datavals]

# We are layering legend elements on top of each other by deriving them from the processed layers,
# each processed layer can contribute a vector of legend elements for each data value in the scale.
for processedlayer in unique_processedlayers
aes_mapping = aesthetic_mapping(processedlayer)

# for each scale in the merge group, we're extracting the keys (of all positional and keyword mappings)
# for which the aesthetic and the scale id match a mapping of the processed layer
# (so basically we're finding all mappings which have used this scale)
all_plotval_kwargs = map(aess, scale_ids, dpds) do aes, scale_id, (_, plotvals, _)
matching_keys = filter(keys(merge(Dictionary(processedlayer.positional), processedlayer.primary, processedlayer.named))) do key
get(aes_mapping, key, nothing) === aes &&
get(processedlayer.scale_mapping, key, nothing) === scale_id
end

# for each mapping which used the scale, we extract the matching plot value
# for example the processed layer might have used the current `AesColor` scale
# on the `color` mapping keyword, so we store `{:color => red}`, `{:color => blue}`, etc,
# one for each value in the categorical scale
map(plotvals) do plotval
MixedArguments(map(key -> plotval, matching_keys))
end
end
end

# we can merge the kwarg dicts from the different scales so that one legend element for this
# processed layer type can represent attributes for multiple scales at once.
# for example, a processed layer with a Scatter might get `{:color => red}` from one scale and
# `{:marker => circle}` from another, which means the legend element is computed using
# `{:color => red, :marker => circle}`
merged_plotval_kwargs = map(eachindex(first(all_plotval_kwargs))) do i
merge([plotval_kwargs[i] for plotval_kwargs in all_plotval_kwargs]...)
end
# we can merge the kwarg dicts from the different scales so that one legend element for this
# processed layer type can represent attributes for multiple scales at once.
# for example, a processed layer with a Scatter might get `{:color => red}` from one scale and
# `{:marker => circle}` from another, which means the legend element is computed using
# `{:color => red, :marker => circle}`
merged_plotval_kwargs = map(eachindex(first(all_plotval_kwargs))) do i
merge([plotval_kwargs[i] for plotval_kwargs in all_plotval_kwargs]...)
end

for (i, kwargs) in enumerate(merged_plotval_kwargs)
# skip the legend element for this processed layer if the kwargs are empty
# which means that no scale in this merge group affected this processedlayer
if !isempty(kwargs)
append!(_legend_els[i], legend_elements(processedlayer, kwargs))
for (i, kwargs) in enumerate(merged_plotval_kwargs)
# skip the legend element for this processed layer if the kwargs are empty
# which means that no scale in this merge group affected this processedlayer
if !isempty(kwargs)
append!(_legend_els[i], legend_elements(processedlayer, kwargs))
end
end
end

append!(datalabs, _datalabs)
append!(legend_els, _legend_els)
end

append!(datalabs, _datalabs)
append!(legend_els, _legend_els)
end
push!(labels, datalabs)
push!(elements_list, legend_els)
end

unused_scales = setdiff(keys(scales_by_symbol), used_scales)
all_keys_that_should_be_there = isempty(labelled_layers) ? keys(scales_by_symbol) : [collect(keys(scales_by_symbol)); :Label]
unused_scales = setdiff(all_keys_that_should_be_there, used_scales)
if !isempty(unused_scales)
error("Found scales that were missing from the manual legend ordering: $(sort!(collect(unused_scales)))")
end
Expand Down
25 changes: 25 additions & 0 deletions test/reference_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -970,3 +970,28 @@ reftest("dodge scatter with rangebars") do
draw!(f[2, 2], spec2, scales(DodgeY = (; width = 1.0)))
f
end

reftest("manual legend labels in visual") do
df_subjects = (; x = repeat(1:10, 10), y = cos.(1:100), id = repeat(1:10, inner = 10))
df_func = (; x = range(1, 10, length = 20), y = cos.(range(1, 10, length = 20)))

spec1 = data(df_subjects) * mapping(:x, :y, group = :id => nonnumeric) * visual(Lines, linestyle = :dash, color = (:black, 0.2), label = "Subject data")
spec2 = data(df_func) * mapping(:x, :y) * (visual(Lines, color = :tomato) + visual(Scatter, markersize = 12, color = :tomato, strokewidth = 2)) * visual(label = L"\cos(x)")

draw(spec1 + spec2)
end

reftest("manual legend order") do
df = (; x = repeat(1:10, 3), y = cos.(1:30), group = repeat(["A", "B", "C"], inner = 10))
spec1 = data(df) * mapping(:x, :y, color = :group) * visual(Lines)

spec2 = data((; x = 1:10, y = cos.(1:10) .+ 2)) * mapping(:x, :y) * visual(Scatter, color = :purple, label = "Scatter")

f = Figure()
fg = draw!(f[1, 1], spec1 + spec2)
legend!(f[1, 2], fg)
legend!(f[1, 3], fg, order = [:Label, :Color])
@test_throws ErrorException legend!(f[1, 4], fg, order = [:Color])
@test_throws ErrorException legend!(f[1, 4], fg, order = [:Label])
f
end
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/reference_tests/manual legend order ref.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading