From 4da0ee38a3f80d49ca01188b3c1cd60dd766c48c Mon Sep 17 00:00:00 2001 From: rafaqz Date: Sun, 3 Sep 2023 02:05:08 +0200 Subject: [PATCH] add Makie extension --- Project.toml | 9 +- ext/DimensionalDataMakie.jl | 280 ++++++++++++++++++++++++++++++++++++ src/DimensionalData.jl | 1 - src/Dimensions/set.jl | 1 + src/plotrecipes.jl | 102 ++----------- 5 files changed, 298 insertions(+), 95 deletions(-) create mode 100644 ext/DimensionalDataMakie.jl diff --git a/Project.toml b/Project.toml index 98f1532c7..f29d1e1e0 100644 --- a/Project.toml +++ b/Project.toml @@ -13,7 +13,6 @@ IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" InvertedIndices = "41ab1584-1d38-5bbf-9106-f11c6c58b48f" IteratorInterfaceExtensions = "82899510-4779-5014-852e-03e436cf321d" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -MakieCore = "20f20a25-4f0e-4fdf-b5d1-57303727442b" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" @@ -30,13 +29,16 @@ Extents = "0.1" IntervalSets = "0.5, 0.6, 0.7" InvertedIndices = "1" IteratorInterfaceExtensions = "1" -MakieCore = "0.6" +Makie = "0.19" PrecompileTools = "1" RecipesBase = "0.7, 0.8, 1" TableTraits = "1" Tables = "1" julia = "1.6" +[extensions] +DimensionalDataMakie = "Makie" + [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" ArrayInterface = "4fba245c-0d91-5ea0-9b3e-6abc04ee57a9" @@ -58,3 +60,6 @@ Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [targets] test = ["Aqua", "ArrayInterface", "BenchmarkTools", "Combinatorics", "CoordinateTransformations", "DataFrames", "Distributions", "Documenter", "ImageFiltering", "ImageTransformations", "OffsetArrays", "Plots", "Random", "SafeTestsets", "StatsPlots", "Test", "Unitful"] + +[weakdeps] +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" diff --git a/ext/DimensionalDataMakie.jl b/ext/DimensionalDataMakie.jl new file mode 100644 index 000000000..2240bccc5 --- /dev/null +++ b/ext/DimensionalDataMakie.jl @@ -0,0 +1,280 @@ +module DimensionalDataMakie + +using DimensionalData, Makie +using DimensionalData.Dimensions, DimensionalData.LookupArrays + +const DD = DimensionalData + +_paired(args...) = map(x -> x isa Pair ? x : x => x, args) + +# 1d plots are scatter by default +for (f1, f2) in _paired(:plot => :scatter, :scatter, :lines, :stem) + docstring = """ + $f1(::AbstractDimArray{<:Any,1}) + + Plot a 1-dimensional `AbstractDimArray` with `Makie.$f2`. + + The X axis will be labelled with the dimension name and and use ticks from its lookup. + """ + @eval begin + @doc $docstring + function Makie.$f1(A::AbstractDimArray{<:Any,1}; attributes...) + A1 = _prepare_for_makie(A) + d = DD.dims(A1, 1) + user_attributes = Makie.Attributes(; attributes...) + plot_attributes = Makie.Attributes(; + axis=(; + xlabel=string(name(d)), + ylabel=DD.label(A), + ), + ) + # Convert categorical to Int + lookup_attributes, newdims = _split_attributes(A1) + A2 = _restore_dim_names(set(A1, d => newdims[1]), A) + args = Makie.convert_arguments(Makie.PointBased(), A2) + merged_attributes = merge(user_attributes, plot_attributes, lookup_attributes) + Makie.$f2(args...; merged_attributes...) + end + end +end + +for (f1, f2) in _paired(:plot => :heatmap, :heatmap, :image, :contour, :contourf) + docstring = """ + $f1(::AbstractDimArray{<:Any,2}) + + Plot a 2-dimensional `AbstractDimArray` with `Makie.$f2`. + + # Keywords + + Keywords for `$f1` work as usual. + + - `dims`: A `Pair` or Tuple of Pair of `Dimension` or `Symbol`. Can be used to + specify dimensions that should be moved to the `X`, `Y` and `Z` dimensions + of the plot. For example `$f1(A, dims=:a => :X)` will use the `:a` dimension + as the `X` dimension in the plot. + """ + @eval begin + @doc $docstring + function Makie.$f1(A::AbstractDimArray{<:Any,2}; dims=(), attributes...) + A1 = _prepare_for_makie(A, dims) + lookup_attributes, newdims = _split_attributes(A1) + A2 = _restore_dim_names(set(A1, map(Pair, newdims, newdims)...), A, dims) + + dx, dy = DD.dims(A2) + user_attributes = Makie.Attributes(; attributes...) + dd_attributes = Makie.Attributes(; + axis=(; + xlabel=DD.label(dx), + ylabel=DD.label(dy), + title=DD.label(A), + ), + ) + merged_attributes = merge(user_attributes, dd_attributes, lookup_attributes) + args = Makie.convert_arguments(Makie.ContinuousSurface(), A2) + merged_attributes = merge(user_attributes, dd_attributes, lookup_attributes) + Makie.$f2(args...; merged_attributes...) + end + end +end + +for (f1, f2) in _paired(:plot => :volume, :volume, :volumeslices) + docstring = """ + $f1(::AbstractDimArray{<:Any,2}) + + Plot a 2-dimensional `AbstractDimArray` with `Makie.$f2`. + + # Keywords + + Keywords for $f1 work as usual. + + - `dims`: A `Pair` or Tuple of Pair of `Dimension` or `Symbol`. Can be used to + specify dimensions that should be moved to the `X`, `Y` and `Z` dimensions + of the plot. For example `$f1(A, dims=:a => :X)` will use the `:a` dimension + as the `X` dimension in the plot. + """ + @eval begin + @doc $docstring + function Makie.$f1(A::AbstractDimArray{<:Any,3}; dims=(), attributes...) + A1 = _prepare_for_makie(A, dims) + dx, dy, dz = DD.dims(A1) + user_attributes = Makie.Attributes(; attributes...) + dd_attributes = Makie.Attributes(; + # axis=(; cant actually set anything here for LScene) + ) + _, newdims = _split_attributes(A1) + merged_attributes = merge(user_attributes, dd_attributes) + A2 = _restore_dim_names(set(A1, map(Pair, newdims, newdims)...), A, dims) + args = Makie.convert_arguments(Makie.VolumeLike(), A2) + Makie.$f2(args...; merged_attributes...) + end + end +end + +for f in (:violin, :boxplot) + docstring = """ + $f(::AbstractDimArray{<:Any,2}) + + Plot a 2-dimensional `AbstractDimArray` with `Makie.$f`. + + At least one dimension should have a `Categorical` `Lookup`, which + will be used to assign categories to the values in the array. + """ + @eval begin + @doc $docstring + function Makie.$f(A::AbstractDimArray{<:Any,2}; attributes...) + categoricaldim = reduce(dims(A); init=nothing) do acc, x + lookup(acc) isa AbstractCategorical ? acc : x + end + isnothing(categoricaldim) && throw(ArgumentError("No dimensions have Categorical lookups")) + categoricallookup = parent(categoricaldim) + otherdim = only(otherdims(A, categoricaldim)) + categories = broadcast_dims((_, c) -> c, A, DimArray(eachindex(categoricaldim), categoricaldim)) + + user_attributes = Makie.Attributes(; attributes...) + dd_attributes = Makie.Attributes(; + axis=(; + xlabel=DD.label(categoricaldim), + xticks=axes(categoricallookup, 1), + xtickformat=I -> map(string, categoricallookup[map(Int, I)]), + ylabel=DD.label(A), + ), + ) + merged_attributes = merge(user_attributes, dd_attributes) + Makie.$f(vec(categories), vec(A); merged_attributes...) + end + end +end + +Makie.plottype(A::AbstractDimArray{<:Any,1}) = Makie.Scatter +Makie.plottype(A::AbstractDimArray{<:Any,2}) = Makie.Heatmap +Makie.plottype(A::AbstractDimArray{<:Any,3}) = Makie.Volume + +# then, define how they are to be converted to plottable data +function Makie.convert_arguments(t::Makie.PointBased, A::AbstractDimArray{<:Any,1}) + A = _prepare_for_makie(A) + xs = lookup(A, 1) + return Makie.convert_arguments(t, parent(xs), parent(A)) +end +function Makie.convert_arguments(t::Makie.PointBased, A::AbstractDimArray{<:Number,2}) + return Makie.convert_arguments(t, parent(A)) +end +function Makie.convert_arguments(t::Makie.SurfaceLike, A::AbstractDimArray{<:Any,2}) + A1 = _prepare_for_makie(A) + xs, ys = map(parent, lookup(A1)) + return Makie.convert_arguments(t, xs, ys, parent(A1)) +end +function Makie.convert_arguments( + t::Makie.DiscreteSurface, A::AbstractDimArray{<:Any,2} +) + A1 = _prepare_for_makie(A) + xs, ys = map(_lookup_edges, lookup(A1)) + return Makie.convert_arguments(t, xs, ys, parent(A1)) +end +function Makie.convert_arguments(t::Makie.VolumeLike, A::AbstractDimArray{<:Any,3}) + A1 = _prepare_for_makie(A) + xs, ys, zs = map(parent, lookup(A1)) + return xs, ys, zs, parent(A1) +end +# fallbacks with descriptive error messages +function Makie.convert_arguments(t::Makie.ConversionTrait, A::AbstractDimArray{<:Any,N}) where {N} + @warn "$t not implemented for `AbstractDimArray` with $N dims, falling back to parent array type" + return Makie.convert_arguments(t, parent(A)) +end + +# Calculate the edges +function _lookup_edges(l::LookupArray) + l = if l isa AbstractSampled + set(l, Intervals()) + else + set(l, Sampled(; sampling=Intervals())) + end + if l == 1 + return [bounds(l)...] + else + ib = intervalbounds(l) + if order(l) isa ForwardOrdered + edges = first.(ib) + push!(edges, last(last(ib))) + else + edges = last.(ib) + push!(edges, first(last(ib))) + end + return edges + end +end + +_split_attributes(A) = _split_attributes(dims(A)) +function _split_attributes(dims::DD.DimTuple) + reduce(dims; init=(Attributes(), ())) do (attr, ds), d + l = lookup(d) + if l isa AbstractCategorical + ticks = axes(l, 1) + int_dim = rebuild(d, NoLookup(ticks)) + dim_attr = if d isa X + Attributes(; axis=(xticks=ticks, xtickformat=I -> map(string, parent(l)[map(Int, I)]))) + elseif d isa Y + Attributes(; axis=(yticks=ticks, ytickformat=I -> map(string, parent(l)[map(Int, I)]))) + else + Attributes(; axis=(zticks=ticks, ztickformat=I -> map(string, parent(l)[map(Int, I)]))) + end + merge(attr, dim_attr), (ds..., int_dim) + else + attr, (ds..., d) + end + end +end +_split_attributes(dim::Dimension) = _split_attributes((dim,)) + +_prepare_for_makie(A, dims=()) = _permute(A, dims) |> _reorder |> _makie_eltype + +_permute(A, replacements::Pair) = _permute(A, (replacements,)) +_permute(A, replacements::Tuple{<:Pair,Vararg{<:Pair}}) = + _permute1(A, map(p -> basetypeof(key2dim(p[1]))(basetypeof(key2dim(p[2]))()), replacements)) +function _permute(A::AbstractDimArray{<:Any,N}, replacements::Tuple) where N + xyz_dims = (X(), Y(), Z())[1:N] + all_replacements = _get_replacement_dims(A, replacements) + A_replaced_dims = set(A, all_replacements...) + # Permute to X/Y/Z order + permutedims(A_replaced_dims, xyz_dims) +end + +_restore_dim_names(A2, A1, replacements::Pair) = _restore_dim_names(A2, A1, (replacements,)) +_restore_dim_names(A2, A1, replacements::Tuple{<:Pair,Vararg{<:Pair}}) = + _restore_dim_names(A2, A1, map(p -> basetypeof(key2dim(p[1]))(basetypeof(key2dim(p[2]))()), replacements)) +function _restore_dim_names(A2, A1, replacements::Tuple=()) + all_replacements = _get_replacement_dims(A1, replacements) + # Invert our replacement dimensions - `set` sets the outer wrapper + # dimension to the inner/wrapped dimension + inverted_replacements = map(all_replacements) do r + basetypeof(val(r))(basetypeof(r)()) + end + @show replacements inverted_replacements + # Set the dimensions back to the originals now they are in the right order + return set(A2, inverted_replacements...) +end + +function _get_replacement_dims(A::AbstractDimArray{<:Any,N}, replacements::Tuple) where N + xyz_dims = (X(), Y(), Z())[1:N] + # Make sure destinations are X/Y/Z only + dest_dims = map(replacements) do d + d1 = basetypeof(val(d))() + d1 in xyz_dims || throw(ArgumentError("`dims` destinations must be in $(map(basetypeof, xyz_dims))")) + d1 + end + replaced_dims = set(basedims(A), replacements...) + # Find remaining dims that are not X/Y/Z + other_source_dims = otherdims(replaced_dims, xyz_dims) + # Assign dest dims from whatever X/Y/Z remain + other_dest_dims = otherdims(xyz_dims, replaced_dims) + # Define the missing replacements + other_replacements = map(rebuild, other_source_dims, other_dest_dims) + return (replacements..., other_replacements...) +end + +_reorder(A) = reorder(A, DD.ForwardOrdered) +_makie_eltype(A) = _missing_or_float32.(A) + +_missing_or_float32(num::Number) = Float32(num) +_mirssing_or_float32(::Missing) = missing + +end diff --git a/src/DimensionalData.jl b/src/DimensionalData.jl index ba9f1b3e6..1600a41f6 100644 --- a/src/DimensionalData.jl +++ b/src/DimensionalData.jl @@ -26,7 +26,6 @@ import Adapt, Extents, InvertedIndices, IteratorInterfaceExtensions, - MakieCore, RecipesBase, PrecompileTools, TableTraits, diff --git a/src/Dimensions/set.jl b/src/Dimensions/set.jl index cfb5de80f..eaca26ddc 100644 --- a/src/Dimensions/set.jl +++ b/src/Dimensions/set.jl @@ -8,6 +8,7 @@ _set(dims_::DimTuple, args::Dimension...; kw...) = _set(dims_, (args..., kwdims( # Convert pairs to wrapped dims and set _set(dims_::DimTuple, p::Pair, ps::Vararg{Pair}) = _set(dims_, (p, ps...)) _set(dims_::DimTuple, ps::Tuple{Vararg{Pair}}) = _set(dims_, pairdims(ps...)) +_set(dims_::DimTuple, ::Tuple{}) = dims_ # Set dims with (possibly unsorted) wrapper vals _set(dims::DimTuple, wrappers::DimTuple) = begin # Check the dimension types match diff --git a/src/plotrecipes.jl b/src/plotrecipes.jl index 1d1576448..7a194af2f 100644 --- a/src/plotrecipes.jl +++ b/src/plotrecipes.jl @@ -53,7 +53,7 @@ end _withaxes(dim, A) end @recipe function f(s::SeriesLike, A::AbstractArray{T,2}) where T - A = permutedims(A, _fwdorderdims(A)) + A = permutedims(A, forward_order_plot_dims(A)) ind, dep = dims(A) :xguide --> label(ind) :yguide --> label(A) @@ -70,7 +70,7 @@ end _withaxes(dim, A) end @recipe function f(s::HistogramLike, A::AbstractArray{T,2}) where T - ds = _revorderdims(A) + ds = reverse_order_plot_dims(A) A = permutedims(A, ds) ind, dep = dims(A) :xguide --> label(A) @@ -85,7 +85,7 @@ end parent(A) end @recipe function f(s::ViolinLike, A::AbstractArray{T,2}) where T - ds = _revorderdims(A) + ds = reverse_order_plot_dims(A) A = permutedims(A, ds) dep, ind = dims(A) :xguide --> label(ind) @@ -96,7 +96,7 @@ end parent(A) end @recipe function f(s::ViolinLike, A::AbstractArray{T,3}) where T - ds = _revorderdims(A) + ds = reverse_order_plot_dims(A) A = permutedims(A, ds) dep2, dep1, ind = dims(A) :xguide --> label(ind) @@ -115,7 +115,7 @@ end parent(A) end @recipe function f(s::HeatMapLike, A::AbstractArray{T,2}) where T - ds = _revorderdims(A) + ds = reverse_order_plot_dims(A) A = permutedims(A, ds) y, x = dims(A) :xguide --> label(x) @@ -130,63 +130,6 @@ end throw(ArgumentError("$(x.seriestype) not implemented in $N dimensions")) end -### Makie.jl recipes - -# First, define default plot types for DimArrays -MakieCore.plottype(A::AbstractDimArray{<:Union{Missing,Number},1}) = MakieCore.Scatter -MakieCore.plottype(A::AbstractDimArray{<:Union{Missing,Number},2}) = MakieCore.Heatmap -# 3d A are a little more complicated - if dim3 is a singleton, then heatmap, otherwise volume -function MakieCore.plottype(A::AbstractDimArray{<:Union{Missing,Number},3}) - if size(A, 3) == 1 - MakieCore.Heatmap - else - MakieCore.Volume - end -end - -# then, define how they are to be converted to plottable data -function MakieCore.convert_arguments(PB::MakieCore.PointBased, A::AbstractDimArray{<:Union{Number,Missing},1}) - A = _prepare_makie(A) - return MakieCore.convert_arguments(PB, map(index, dims(A))..., parent(A)) -end -# allow 3d A to be plotted as volumes -function MakieCore.convert_arguments( - ::MakieCore.VolumeLike, A::AbstractDimArray{<:Union{Real,Missing},3} -) - A = _prepare_makie(A) - xs, ys, zs = lookup(A) - return (xs, ys, zs, parent(A)) -end -# allow plotting 3d A with singleton third dimension (basically 2d A) -function MakieCore.convert_arguments( - ::MakieCore.SurfaceLike, A::AbstractDimArray{<:Union{Missing,Number},2} -) - A = _prepare_makie(A) - xs, ys = lookup(A) - return (xs, ys, parent(A)) -end -function MakieCore.convert_arguments( - ::MakieCore.PointBased, A::AbstractDimArray{<:Union{Missing,Number},1} -) - A = _prepare_makie(A) - xs = lookup(A, 1) - return parent(xs), parent(A) -end -function MakieCore.convert_arguments( - ::MakieCore.DiscreteSurface, A::AbstractDimArray{<:Union{Missing,Number},2} -) - A = _prepare_makie(A) - xs, ys = map(_lookup_edges, lookup(A)) - return (xs, ys, parent(A)) -end -# fallbacks with descriptive error messages -MakieCore.convert_arguments(t::MakieCore.ConversionTrait, r::AbstractDimArray) = - _makie_not_implemented_error(t, r) - -### Shared utils - -_fwdorderdims(x) = dims(dims(x), (TimeDim, XDim, IndependentDim, YDim, ZDim, DependentDim, DependentDim, Dimension, Dimension, Dimension)) -_revorderdims(x) = reverse(_fwdorderdims(reverse(dims(x)))) _withaxes(dim::Dimension, A::AbstractDimArray) = _withaxes(lookup(dim), index(dim), parent(A)) @@ -211,6 +154,8 @@ _yticks!(attr, s, ::Categorical, index) = RecipesBase.is_explicit(attr, :yticks) || (attr[:yticks] = (eachindex(index), index)) _yticks!(attr, s, ::LookupArray, index) = nothing +### Shared utils + """ refdims_title(A::AbstractDimArray) refdims_title(refdims::Tuple) @@ -242,33 +187,6 @@ function refdims_title(lookup::LookupArray, refdim::Dimension; kw...) end end - -function _lookup_edges(l::LookupArray) - l = if l isa AbstractSampled - set(l, Intervals()) - else - set(l, Sampled(; sampling=Intervals())) - end - if l == 1 - return [bounds(l)...] - else - ib = intervalbounds(l) - if order(l) isa ForwardOrdered - edges = first.(ib) - push!(edges, last(last(ib))) - else - edges = last.(ib) - push!(edges, first(last(ib))) - end - return edges - end -end - -_prepare_makie(A) = _missing_or_float32.(A) |> _permute_fwd |> _reorder - -_missing_or_float32(num::Number) = Float32(num) -_missing_or_float32(::Missing) = missing - -_reorder(A) = reorder(A, DD.ForwardOrdered) -_permute_fwd(A) = permutedims(A, _fwdorderdims(A)) -_permute_rev(A) = permutedims(A, _revorderdims(A)) +const PLOT_DIMENSION_ORDER = (TimeDim, XDim, IndependentDim, YDim, ZDim, DependentDim, DependentDim, Dimension, Dimension, Dimension) +forward_order_plot_dims(x) = dims(dims(x), PLOT_DIMENSION_ORDER) +reverse_order_plot_dims(x) = reverse(forward_order_plot_dims(reverse(dims(x))))