Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into as/no_hardcoded_axi…
Browse files Browse the repository at this point in the history
…s_types
  • Loading branch information
asinghvi17 committed Dec 14, 2024
2 parents 81c3f7f + 3db46be commit 4bfb769
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 89 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

## v0.8.11 - 2024-09-25

- Fixed lexicographic natural sorting of tuples (this would fall back to default sort order before) [#568](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/568).

## v0.8.10 - 2024-09-24

- Fixed markercolor in `ScatterLines` legends when it did not match `color` [#567](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/567).

## v0.8.9 - 2024-09-24

- 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
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "AlgebraOfGraphics"
uuid = "cbdf2221-f076-402e-a563-3d30da359d67"
authors = ["Pietro Vertechi", "Julius Krumbiegel"]
version = "0.8.8"
version = "0.8.11"

[deps]
Accessors = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697"
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
202 changes: 115 additions & 87 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 Expand Up @@ -304,15 +330,17 @@ function legend_elements(T::Type{Scatter}, attributes, scale_args::MixedArgument
end

function legend_elements(T::Type{ScatterLines}, attributes, scale_args::MixedArguments)
color = _get(T, scale_args, attributes, :color)
markercolor = _get(T, scale_args, attributes, :markercolor)
[
LineElement(
color = _get(T, scale_args, attributes, :color),
color = color,
linestyle = _get(T, scale_args, attributes, :linestyle),
linewidth = _get(T, scale_args, attributes, :linewidth),
linepoints = [Point2f(0, 0.5), Point2f(1, 0.5)],
),
MarkerElement(
color = _get(T, scale_args, attributes, :color),
color = markercolor === Makie.automatic ? color : markercolor,
markerpoints = [Point2f(0.5, 0.5)],
marker = _get(T, scale_args, attributes, :marker),
markerstrokewidth = _get(T, scale_args, attributes, :strokewidth),
Expand Down
15 changes: 15 additions & 0 deletions src/scales.jl
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,21 @@ push_different!(v, val) = !isempty(v) && isequal(last(v), val) || push!(v, val)
natural_lt(x, y) = isless(x, y)
natural_lt(x::AbstractString, y::AbstractString) = NaturalSort.natural(x, y)

# the below `natural_lt`s are copying Julia's `isless` implementation
natural_lt(::Tuple{}, ::Tuple{}) = false
natural_lt(::Tuple{}, ::Tuple) = true
natural_lt(::Tuple, ::Tuple{}) = false

# """
# natural_lt(t1::Tuple, t2::Tuple)

# Return `true` when `t1` is less than `t2` in lexicographic order.
# """
function natural_lt(t1::Tuple, t2::Tuple)
a, b = t1[1], t2[1]
natural_lt(a, b) || (isequal(a, b) && natural_lt(tail(t1), tail(t2)))
end

function mergesorted(v1, v2)
issorted(v1; lt = natural_lt) && issorted(v2; lt = natural_lt) || throw(ArgumentError("Arguments must be sorted"))
T = promote_type(eltype(v1), eltype(v2))
Expand Down
Loading

0 comments on commit 4bfb769

Please sign in to comment.