diff --git a/Project.toml b/Project.toml index 82ab017d..e44beb48 100644 --- a/Project.toml +++ b/Project.toml @@ -53,12 +53,12 @@ julia = "1" FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" GeometryTypes = "4d00f742-c7ba-57c2-abde-4428a4b178cb" Gtk = "4c0ca9eb-093a-5379-98c5-f87ac0bbbf44" +HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" ImageMagick = "6218d12a-5da1-5696-b52f-db25d2ecc6d1" Images = "916415d5-f1e6-5110-898d-aaa5f9f070e0" LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433" OffsetArrays = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" PGFPlotsX = "8314cec4-20b6-5062-9cdb-752b83310925" -HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" RDatasets = "ce6b1742-4840-55fa-b093-852dadbb1d8b" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" diff --git a/src/Plots.jl b/src/Plots.jl index c7a26958..d8a3fe74 100644 --- a/src/Plots.jl +++ b/src/Plots.jl @@ -163,6 +163,24 @@ using .PlotMeasures import .PlotMeasures: Length, AbsoluteLength, Measure, width, height # --------------------------------------------------------- +include("RecipePipeline/RecipePipeline.jl") +import .RecipePipeline +import .RecipePipeline: SliceIt, + DefaultsDict, + Formatted, + AbstractSurface, + Surface, + Volume, + is3d, + is_surface, + needs_3d_axes, + group_as_matrix, + reset_kw!, + pop_kw!, + scale_func, + inverse_scale_func, + unzip + include("types.jl") include("utils.jl") include("components.jl") diff --git a/src/RecipePipeline/RecipePipeline.jl b/src/RecipePipeline/RecipePipeline.jl new file mode 100644 index 00000000..03cc24e6 --- /dev/null +++ b/src/RecipePipeline/RecipePipeline.jl @@ -0,0 +1,97 @@ +module RecipePipeline + +import RecipesBase +import RecipesBase: @recipe, @series, RecipeData, is_explicit +import PlotUtils # tryrange and adapted_grid + +export recipe_pipeline! +# Plots relies on these: +export SliceIt, + DefaultsDict, + Formatted, + AbstractSurface, + Surface, + Volume, + is3d, + is_surface, + needs_3d_axes, + group_as_matrix, + reset_kw!, + pop_kw!, + scale_func, + inverse_scale_func, + unzip +# API +export warn_on_recipe_aliases, + splittable_attribute, + split_attribute, + process_userrecipe!, + get_axis_limits, + is_axis_attribute, + type_alias, + plot_setup!, + slice_series_attributes! + +include("api.jl") +include("utils.jl") +include("series.jl") +include("group.jl") +include("user_recipe.jl") +include("type_recipe.jl") +include("plot_recipe.jl") +include("series_recipe.jl") + + +""" + recipe_pipeline!(plt, plotattributes, args) + +Recursively apply user recipes, type recipes, plot recipes and series recipes to build a +list of `Dict`s, each corresponding to a series. At the beginning `plotattributes` +contains only the keyword arguments passed in by the user. Add all series to the plot +bject `plt` and return it. +""" +function recipe_pipeline!(plt, plotattributes, args) + plotattributes[:plot_object] = plt + + # -------------------------------- + # "USER RECIPES" + # -------------------------------- + + # process user and type recipes + kw_list = _process_userrecipes!(plt, plotattributes, args) + + # -------------------------------- + # "PLOT RECIPES" + # -------------------------------- + + # The "Plot recipe" acts like a series type, and is processed before the plot layout + # is created, which allows for setting layouts and other plot-wide attributes. + # We get inputs which have been fully processed by "user recipes" and "type recipes", + # so we can expect standard vectors, surfaces, etc. No defaults have been set yet. + + kw_list = _process_plotrecipes!(plt, kw_list) + + # -------------------------------- + # Plot/Subplot/Layout setup + # -------------------------------- + + plot_setup!(plt, plotattributes, kw_list) + + # At this point, `kw_list` is fully decomposed into individual series... one KW per + # series. The next step is to recursively apply series recipes until the backend + # supports that series type. + + # -------------------------------- + # "SERIES RECIPES" + # -------------------------------- + + _process_seriesrecipes!(plt, kw_list) + + # -------------------------------- + # Return processed plot object + # -------------------------------- + + return plt +end + +end diff --git a/src/RecipePipeline/api.jl b/src/RecipePipeline/api.jl new file mode 100644 index 00000000..feb9350a --- /dev/null +++ b/src/RecipePipeline/api.jl @@ -0,0 +1,142 @@ +## Warnings + +""" + warn_on_recipe_aliases!(plt, plotattributes, recipe_type, args...) + +Warn if an alias is dedected in `plotattributes` after a recipe of type `recipe_type` is +applied to 'args'. `recipe_type` is either `:user`, `:type`, `:plot` or `:series`. +""" +function warn_on_recipe_aliases!(plt, plotattributes, recipe_type, args...) end + + +## Grouping + +""" + splittable_attribute(plt, key, val, len) + +Returns `true` if the attribute `key` with the value `val` can be split into groups with +group provided as a vector of length `len`, `false` otherwise. +""" +splittable_attribute(plt, key, val, len) = false +splittable_attribute(plt, key, val::AbstractArray, len) = + !(key in (:group, :color_palette)) && length(axes(val, 1)) == len +splittable_attribute(plt, key, val::Tuple, n) = all(splittable_attribute.(key, val, len)) + + +""" + split_attribute(plt, key, val, indices) + +Select the proper indices from `val` for attribute `key`. +""" +split_attribute(plt, key, val::AbstractArray, indices) = + val[indices, fill(Colon(), ndims(val) - 1)...] +split_attribute(plt, key, val::Tuple, indices) = + Tuple(split_attribute(key, v, indices) for v in val) + + +## Preprocessing attributes + +""" + preprocess_attributes!(plt, plotattributes) + +Any plotting package specific preprocessing of user or recipe input happens here. +For example, Plots replaces aliases and expands magic arguments. +""" +function preprocess_attributes!(plt, plotattributes) end + +# TODO: should the Plots version be defined as fallback in RecipePipeline? +""" + is_subplot_attribute(plt, attr) + +Returns `true` if `attr` is a subplot attribute, otherwise `false`. +""" +is_subplot_attribute(plt, attr) = false + +# TODO: should the Plots version be defined as fallback in RecipePipeline? +""" + is_axis_attribute(plt, attr) + +Returns `true` if `attr` is an axis attribute, i.e. it applies to `xattr`, `yattr` and +`zattr`, otherwise `false`. +""" +is_axis_attribute(plt, attr) = false + + +## User recipes + +""" + process_userrecipe!(plt, attributes_list, attributes) + +Do plotting package specific post-processing and add series attributes to attributes_list. +For example, Plots increases the number of series in `plt`, sets `:series_plotindex` in +attributes and possible adds new series attributes for errorbars or smooth. +""" +function process_userrecipe!(plt, attributes_list, attributes) + push!(attributes_list, attributes) +end + +""" + get_axis_limits(plt, letter) + +Get the limits for the axis specified by `letter` (`:x`, `:y` or `:z`) in `plt`. If it +errors, `tryrange` from PlotUtils is used. +""" +get_axis_limits(plt, letter) = ErrorException("Axis limits not defined.") + + +## Plot recipes + +""" + type_alias(plt, st) + +Return the seriestype alias for `st`. +""" +type_alias(plt, st) = st + + +## Plot setup + +""" + plot_setup!(plt, plotattributes, kw_list) + +Setup plot, subplots and layouts. +For example, Plots creates the backend figure, initializes subplots, expands extrema and +links subplot axes. +""" +function plot_setup!(plt, plotattributes, kw_list) end + + +## Series recipes + +""" + slice_series_attributes!(plt, kw_list, kw) + +For attributes given as vector with one element per series, only select the value for +current series. +""" +function slice_series_attributes!(plt, kw_list, kw) end + + +""" + series_defaults(plt) + +Returns a `Dict` storing the defaults for series attributes. +""" +series_defaults(plt) = Dict{Symbol, Any}() + +# TODO: Add a more sensible fallback including e.g. path, scatter, ... +""" + is_seriestype_supported(plt, st) + +Check if the plotting package natively supports the seriestype `st`. +""" +is_seriestype_supported(plt, st) = false + +""" + add_series!(plt, kw) + +Adds the series defined by `kw` to the plot object. +For example Plots updates the current subplot arguments, expands extrema and pushes the +the series to the series_list of `plt`. +""" +function add_series!(plt, kw) end diff --git a/src/RecipePipeline/group.jl b/src/RecipePipeline/group.jl new file mode 100644 index 00000000..ca9d46c5 --- /dev/null +++ b/src/RecipePipeline/group.jl @@ -0,0 +1,122 @@ +"A special type that will break up incoming data into groups, and allow for easier creation of grouped plots" +mutable struct GroupBy + group_labels::Vector # length == numGroups + group_indices::Vector{Vector{Int}} # list of indices for each group +end + +# this is when given a vector-type of values to group by +function _extract_group_attributes(v::AVec, args...; legend_entry = string) + group_labels = sort(collect(unique(v))) + n = length(group_labels) + if n > 100 + @warn("You created n=$n groups... Is that intended?") + end + group_indices = Vector{Int}[filter(i -> v[i] == glab, eachindex(v)) for glab in group_labels] + GroupBy(map(legend_entry, group_labels), group_indices) +end + +legend_entry_from_tuple(ns::Tuple) = join(ns, ' ') + +# this is when given a tuple of vectors of values to group by +function _extract_group_attributes(vs::Tuple, args...) + isempty(vs) && return GroupBy([""], [axes(args[1],1)]) + v = map(tuple, vs...) + _extract_group_attributes(v, args...; legend_entry = legend_entry_from_tuple) +end + +# allow passing NamedTuples for a named legend entry +legend_entry_from_tuple(ns::NamedTuple) = + join(["$k = $v" for (k, v) in pairs(ns)], ", ") + +function _extract_group_attributes(vs::NamedTuple, args...) + isempty(vs) && return GroupBy([""], [axes(args[1],1)]) + v = map(NamedTuple{keys(vs)}∘tuple, values(vs)...) + _extract_group_attributes(v, args...; legend_entry = legend_entry_from_tuple) +end + +# expecting a mapping of "group label" to "group indices" +function _extract_group_attributes(idxmap::Dict{T,V}, args...) where {T, V<:AVec{Int}} + group_labels = sortedkeys(idxmap) + group_indices = Vector{Int}[collect(idxmap[k]) for k in group_labels] + GroupBy(group_labels, group_indices) +end + +filter_data(v::AVec, idxfilter::AVec{Int}) = v[idxfilter] +filter_data(v, idxfilter) = v + +function filter_data!(plotattributes::AKW, idxfilter) + for s in (:x, :y, :z) + plotattributes[s] = filter_data(get(plotattributes, s, nothing), idxfilter) + end +end + +function _filter_input_data!(plotattributes::AKW) + idxfilter = pop!(plotattributes, :idxfilter, nothing) + if idxfilter !== nothing + filter_data!(plotattributes, idxfilter) + end +end + +function groupedvec2mat(x_ind, x, y::AbstractArray, groupby, def_val = y[1]) + y_mat = Array{promote_type(eltype(y), typeof(def_val))}( + undef, + length(keys(x_ind)), + length(groupby.group_labels), + ) + fill!(y_mat, def_val) + for i in eachindex(groupby.group_labels) + xi = x[groupby.group_indices[i]] + yi = y[groupby.group_indices[i]] + y_mat[getindex.(Ref(x_ind), xi), i] = yi + end + return y_mat +end + +groupedvec2mat(x_ind, x, y::Tuple, groupby) = + Tuple(groupedvec2mat(x_ind, x, v, groupby) for v in y) + +group_as_matrix(t) = false + +# split the group into 1 series per group, and set the label and idxfilter for each +@recipe function f(groupby::GroupBy, args...) + plt = plotattributes[:plot_object] + group_length = maximum(union(groupby.group_indices...)) + if !(group_as_matrix(args[1])) + for (i, glab) in enumerate(groupby.group_labels) + @series begin + label --> string(glab) + idxfilter --> groupby.group_indices[i] + for (key, val) in plotattributes + if splittable_attribute(plt, key, val, group_length) + :($key) := split_attribute(plt, key, val, groupby.group_indices[i]) + end + end + args + end + end + else + g = args[1] + if length(g.args) == 1 + x = zeros(Int, group_length) + for indexes in groupby.group_indices + x[indexes] = eachindex(indexes) + end + last_args = g.args + else + x = g.args[1] + last_args = g.args[2:end] + end + x_u = unique(sort(x)) + x_ind = Dict(zip(x_u, eachindex(x_u))) + for (key, val) in plotattributes + if splittable_kw(key, val, group_length) + :($key) := groupedvec2mat(x_ind, x, val, groupby) + end + end + label --> reshape(groupby.group_labels, 1, :) + typeof(g)(( + x_u, + (groupedvec2mat(x_ind, x, arg, groupby, NaN) for arg in last_args)..., + )) + end +end diff --git a/src/RecipePipeline/plot_recipe.jl b/src/RecipePipeline/plot_recipe.jl new file mode 100644 index 00000000..6b7e05c3 --- /dev/null +++ b/src/RecipePipeline/plot_recipe.jl @@ -0,0 +1,46 @@ +""" + _process_plotrecipes!(plt, kw_list) + +Grab the first in line to be processed and pass it through `apply_recipe` to generate a +list of `RecipeData` objects. +If we applied a "plot recipe" without error, then add the returned datalist's KWs, +otherwise we just add the original KW. +""" +function _process_plotrecipes!(plt, kw_list) + still_to_process = kw_list + kw_list = KW[] + while !isempty(still_to_process) + next_kw = popfirst!(still_to_process) + _process_plotrecipe(plt, next_kw, kw_list, still_to_process) + end + return kw_list +end + + +function _process_plotrecipe(plt, kw, kw_list, still_to_process) + if !isa(get(kw, :seriestype, nothing), Symbol) + # seriestype was never set, or it's not a Symbol, so it can't be a plot recipe + push!(kw_list, kw) + return + end + try + st = kw[:seriestype] + st = kw[:seriestype] = type_alias(plt, st) + datalist = RecipesBase.apply_recipe(kw, Val{st}, plt) + warn_on_recipe_aliases!(plt, datalist, :plot, st) + for data in datalist + preprocess_attributes!(plt, data.plotattributes) + if data.plotattributes[:seriestype] == st + error("Plot recipe $st returned the same seriestype: $(data.plotattributes)") + end + push!(still_to_process, data.plotattributes) + end + catch err + if isa(err, MethodError) + push!(kw_list, kw) + else + rethrow() + end + end + return +end diff --git a/src/RecipePipeline/series.jl b/src/RecipePipeline/series.jl new file mode 100644 index 00000000..e967fa4c --- /dev/null +++ b/src/RecipePipeline/series.jl @@ -0,0 +1,170 @@ +const FuncOrFuncs{F} = Union{F, Vector{F}, Matrix{F}} +const MaybeNumber = Union{Number, Missing} +const MaybeString = Union{AbstractString, Missing} +const DataPoint = Union{MaybeNumber, MaybeString} + +_prepare_series_data(x) = error("Cannot convert $(typeof(x)) to series data for plotting") +_prepare_series_data(::Nothing) = nothing +_prepare_series_data(t::Tuple{T, T}) where {T <: Number} = t +_prepare_series_data(f::Function) = f +_prepare_series_data(ar::AbstractRange{<:Number}) = ar +function _prepare_series_data(a::AbstractArray{<:MaybeNumber}) + f = isimmutable(a) ? replace : replace! + a = f(x -> ismissing(x) || isinf(x) ? NaN : x, map(float, a)) +end +_prepare_series_data(a::AbstractArray{<:Missing}) = fill(NaN, axes(a)) +_prepare_series_data(a::AbstractArray{<:MaybeString}) = + replace(x -> ismissing(x) ? "" : x, a) +_prepare_series_data(s::Surface{<:AMat{<:MaybeNumber}}) = + Surface(_prepare_series_data(s.surf)) +_prepare_series_data(s::Surface) = s # non-numeric Surface, such as an image +_prepare_series_data(v::Volume) = + Volume(_prepare_series_data(v.v), v.x_extents, v.y_extents, v.z_extents) + +# default: assume x represents a single series +_series_data_vector(x, plotattributes) = [_prepare_series_data(x)] + +# fixed number of blank series +_series_data_vector(n::Integer, plotattributes) = [zeros(0) for i in 1:n] + +# vector of data points is a single series +_series_data_vector(v::AVec{<:DataPoint}, plotattributes) = [_prepare_series_data(v)] + +# list of things (maybe other vectors, functions, or something else) +function _series_data_vector(v::AVec, plotattributes) + if all(x -> x isa MaybeNumber, v) + _series_data_vector(Vector{MaybeNumber}(v), plotattributes) + elseif all(x -> x isa MaybeString, v) + _series_data_vector(Vector{MaybeString}(v), plotattributes) + else + vcat((_series_data_vector(vi, plotattributes) for vi in v)...) + end +end + +# Matrix is split into columns +function _series_data_vector(v::AMat{<:DataPoint}, plotattributes) + if is3d(plotattributes) + [_prepare_series_data(Surface(v))] + else + [_prepare_series_data(v[:, i]) for i in axes(v, 2)] + end +end + +# -------------------------------------------------------------------- +# Fillranges & ribbons + + +_process_fillrange(range::Number, plotattributes) = [range] +_process_fillrange(range, plotattributes) = _series_data_vector(range, plotattributes) + +_process_ribbon(ribbon::Number, plotattributes) = [ribbon] +_process_ribbon(ribbon, plotattributes) = _series_data_vector(ribbon, plotattributes) +# ribbon as a tuple: (lower_ribbons, upper_ribbons) +_process_ribbon(ribbon::Tuple{S, T}, plotattributes) where {S, T} = collect(zip( + _series_data_vector(ribbon[1], plotattributes), + _series_data_vector(ribbon[2], plotattributes), +)) + + +# -------------------------------------------------------------------- + +_compute_x(x::Nothing, y::Nothing, z) = axes(z, 1) +_compute_x(x::Nothing, y, z) = axes(y, 1) +_compute_x(x::Function, y, z) = map(x, y) +_compute_x(x, y, z) = x + +_compute_y(x::Nothing, y::Nothing, z) = axes(z, 2) +_compute_y(x, y::Function, z) = map(y, x) +_compute_y(x, y, z) = y + +_compute_z(x, y, z::Function) = map(z, x, y) +_compute_z(x, y, z::AbstractMatrix) = Surface(z) +_compute_z(x, y, z::Nothing) = nothing +_compute_z(x, y, z) = z + +_nobigs(v::AVec{BigFloat}) = map(Float64, v) +_nobigs(v::AVec{BigInt}) = map(Int64, v) +_nobigs(v) = v + +@noinline function _compute_xyz(x, y, z) + x = _compute_x(x, y, z) + y = _compute_y(x, y, z) + z = _compute_z(x, y, z) + _nobigs(x), _nobigs(y), _nobigs(z) +end + +# not allowed +_compute_xyz(x::Nothing, y::FuncOrFuncs{F}, z) where {F <: Function} = + error("If you want to plot the function `$y`, you need to define the x values!") +_compute_xyz(x::Nothing, y::Nothing, z::FuncOrFuncs{F}) where {F <: Function} = + error("If you want to plot the function `$z`, you need to define x and y values!") +_compute_xyz(x::Nothing, y::Nothing, z::Nothing) = error("x/y/z are all nothing!") + +# -------------------------------------------------------------------- + + +# we are going to build recipes to do the processing and splitting of the args + +# -------------------------------------------------------------------- +# The catch-all SliceIt recipe +# -------------------------------------------------------------------- + +# ensure we dispatch to the slicer +struct SliceIt end + +# TODO: Should ribbon and fillrange be handled by the plotting package? + +# The `SliceIt` recipe finishes user and type recipe processing. +# It splits processed data into individual series data, stores in copied `plotattributes` +# for each series and returns no arguments. +@recipe function f(::Type{SliceIt}, x, y, z) + + # handle data with formatting attached + if typeof(x) <: Formatted + xformatter := x.formatter + x = x.data + end + if typeof(y) <: Formatted + yformatter := y.formatter + y = y.data + end + if typeof(z) <: Formatted + zformatter := z.formatter + z = z.data + end + + xs = _series_data_vector(x, plotattributes) + ys = _series_data_vector(y, plotattributes) + zs = _series_data_vector(z, plotattributes) + + fr = pop!(plotattributes, :fillrange, nothing) + fillranges = _process_fillrange(fr, plotattributes) + mf = length(fillranges) + + rib = pop!(plotattributes, :ribbon, nothing) + ribbons = _process_ribbon(rib, plotattributes) + mr = length(ribbons) + + mx = length(xs) + my = length(ys) + mz = length(zs) + if mx > 0 && my > 0 && mz > 0 + for i in 1:max(mx, my, mz) + # add a new series + di = copy(plotattributes) + xi, yi, zi = xs[mod1(i, mx)], ys[mod1(i, my)], zs[mod1(i, mz)] + di[:x], di[:y], di[:z] = _compute_xyz(xi, yi, zi) + + # handle fillrange + fr = fillranges[mod1(i, mf)] + di[:fillrange] = isa(fr, Function) ? map(fr, di[:x]) : fr + + # handle ribbons + rib = ribbons[mod1(i, mr)] + di[:ribbon] = isa(rib, Function) ? map(rib, di[:x]) : rib + + push!(series_list, RecipeData(di, ())) + end + end + nothing # don't add a series for the main block +end diff --git a/src/RecipePipeline/series_recipe.jl b/src/RecipePipeline/series_recipe.jl new file mode 100644 index 00000000..37bb6a4c --- /dev/null +++ b/src/RecipePipeline/series_recipe.jl @@ -0,0 +1,62 @@ +""" + _process_seriesrecipes!(plt, kw_list) + +Recursively apply series recipes until the backend supports the seriestype +""" +function _process_seriesrecipes!(plt, kw_list) + for kw in kw_list + # in series attributes given as vector with one element per series, + # select the value for current series + slice_series_attributes!(plt, kw_list, kw) + + series_attr = DefaultsDict(kw, series_defaults(plt)) + # now we have a fully specified series, with colors chosen. we must recursively + # handle series recipes, which dispatch on seriestype. If a backend does not + # natively support a seriestype, we check for a recipe that will convert that + # series type into one made up of lower-level components. + # For example, a histogram is just a bar plot with binned data, a bar plot is + # really a filled step plot, and a step plot is really just a path. So any backend + # that supports drawing a path will implicitly be able to support step, bar, and + # histogram plots (and any recipes that use those components). + _process_seriesrecipe(plt, series_attr) + end +end + +# this method recursively applies series recipes when the seriestype is not supported +# natively by the backend +function _process_seriesrecipe(plt, plotattributes) + # replace seriestype aliases + st = Symbol(plotattributes[:seriestype]) + st = plotattributes[:seriestype] = type_alias(plt, st) + + # shapes shouldn't have fillrange set + if plotattributes[:seriestype] == :shape + plotattributes[:fillrange] = nothing + end + + # if it's natively supported, finalize processing and pass along to the backend, + # otherwise recurse + if is_seriestype_supported(plt, st) + add_series!(plt, plotattributes) + else + # get a sub list of series for this seriestype + x, y, z = plotattributes[:x], plotattributes[:y], plotattributes[:z] + datalist = RecipesBase.apply_recipe(plotattributes, Val{st}, x, y, z) + warn_on_recipe_aliases!(plt, datalist, :series, st) + + # assuming there was no error, recursively apply the series recipes + for data in datalist + if isa(data, RecipeData) + preprocess_attributes!(plt, data.plotattributes) + if data.plotattributes[:seriestype] == st + error("The seriestype didn't change in series recipe $st. This will cause a StackOverflow.") + end + _process_seriesrecipe(plt, data.plotattributes) + else + @warn("Unhandled recipe: $(data)") + break + end + end + end + nothing +end diff --git a/src/RecipePipeline/type_recipe.jl b/src/RecipePipeline/type_recipe.jl new file mode 100644 index 00000000..527fbae6 --- /dev/null +++ b/src/RecipePipeline/type_recipe.jl @@ -0,0 +1,94 @@ +# this is the default "type recipe"... just pass the object through +@recipe f(::Type{T}, v::T) where {T} = v + +# this should catch unhandled "series recipes" and error with a nice message +@recipe f(::Type{V}, x, y, z) where {V <: Val} = + error("The backend must not support the series type $V, and there isn't a series recipe defined.") + +""" + _apply_type_recipe(plotattributes, v::T, letter) + +Apply the type recipe with signature `(::Type{T}, ::T)`. +""" +function _apply_type_recipe(plotattributes, v, letter) + _preprocess_axis_args!(plotattributes, letter) + rdvec = RecipesBase.apply_recipe(plotattributes, typeof(v), v) + warn_on_recipe_aliases!(plotattributes[:plot_object], plotattributes, :type, typeof(v)) + _postprocess_axis_args!(plotattributes, letter) + return rdvec[1].args[1] +end + +# Handle type recipes when the recipe is defined on the elements. +# This sort of recipe should return a pair of functions... one to convert to number, +# and one to format tick values. +function _apply_type_recipe(plotattributes, v::AbstractArray, letter) + plt = plotattributes[:plot_object] + _preprocess_axis_args!(plotattributes, letter) + # First we try to apply an array type recipe. + w = RecipesBase.apply_recipe(plotattributes, typeof(v), v)[1].args[1] + warn_on_recipe_aliases!(plt, plotattributes, :type, typeof(v)) + # If the type did not change try it element-wise + if typeof(v) == typeof(w) + isempty(skipmissing(v)) && return Float64[] + x = first(skipmissing(v)) + args = RecipesBase.apply_recipe(plotattributes, typeof(x), x)[1].args + warn_on_recipe_aliases!(plt, plotattributes, :type, typeof(x)) + _postprocess_axis_args!(plotattributes, letter) + if length(args) == 2 && all(arg -> arg isa Function, args) + numfunc, formatter = args + return Formatted(map(numfunc, v), formatter) + else + return v + end + end + _postprocess_axis_args!(plotattributes, letter) + return w +end + +# special handling for Surface... need to properly unwrap and re-wrap +_apply_type_recipe(plotattributes, v::Surface{<:AMat{<:DataPoint}}) = v +function _apply_type_recipe(plotattributes, v::Surface) + ret = _apply_type_recipe(plotattributes, v.surf) + if typeof(ret) <: Formatted + Formatted(Surface(ret.data), ret.formatter) + else + Surface(ret.data) + end +end + +# don't do anything for datapoints or nothing +_apply_type_recipe(plotattributes, v::AbstractArray{<:DataPoint}, letter) = v +_apply_type_recipe(plotattributes, v::Nothing, letter) = v + +# axis args before type recipes should still be mapped to all axes +function _preprocess_axis_args!(plotattributes) + plt = plotattributes[:plot_object] + for (k, v) in plotattributes + if is_axis_attribute(plt, k) + pop!(plotattributes, k) + for l in (:x, :y, :z) + lk = Symbol(l, k) + haskey(plotattributes, lk) || (plotattributes[lk] = v) + end + end + end +end +function _preprocess_axis_args!(plotattributes, letter) + plotattributes[:letter] = letter + _preprocess_axis_args!(plotattributes) +end + +# axis args in type recipes should only be applied to the current axis +function _postprocess_axis_args!(plotattributes, letter) + plt = plotattributes[:plot_object] + pop!(plotattributes, :letter) + if letter in (:x, :y, :z) + for (k, v) in plotattributes + if is_axis_attribute(plt, k) + pop!(plotattributes, k) + lk = Symbol(letter, k) + haskey(plotattributes, lk) || (plotattributes[lk] = v) + end + end + end +end diff --git a/src/RecipePipeline/user_recipe.jl b/src/RecipePipeline/user_recipe.jl new file mode 100644 index 00000000..0023474f --- /dev/null +++ b/src/RecipePipeline/user_recipe.jl @@ -0,0 +1,330 @@ +""" + _process_userrecipes(plt, plotattributes, args) + +Wrap input arguments in a `RecipeData' vector and recursively apply user recipes and type +recipes on the first element. Prepend the returned `RecipeData` vector. If an element with +empy `args` is returned pop it from the vector, finish up, and it to vector of `Dict`s with +processed series. When all arguments are processed return the series `Dict`. +""" +function _process_userrecipes!(plt, plotattributes, args) + still_to_process = _recipedata_vector(plt, plotattributes, args) + + # for plotting recipes, swap out the args and update the parameter dictionary + # we are keeping a stack of series that still need to be processed. + # each pass through the loop, we pop one off and apply the recipe. + # the recipe will return a list a Series objects... the ones that are + # finished (no more args) get added to the kw_list, the ones that are not + # are placed on top of the stack and are then processed further. + kw_list = KW[] + while !isempty(still_to_process) + # grab the first in line to be processed and either add it to the kw_list or + # pass it through apply_recipe to generate a list of RecipeData objects + # (data + attributes) for further processing. + next_series = popfirst!(still_to_process) + # recipedata should be of type RecipeData. + # if it's not then the inputs must not have been fully processed by recipes + if !(typeof(next_series) <: RecipeData) + error("Inputs couldn't be processed... expected RecipeData but got: $next_series") + end + if isempty(next_series.args) + _finish_userrecipe!(plt, kw_list, next_series) + else + rd_list = + RecipesBase.apply_recipe(next_series.plotattributes, next_series.args...) + warn_on_recipe_aliases!(plt, rd_list, :user, next_series.args...) + prepend!(still_to_process, rd_list) + end + end + + # don't allow something else to handle it + plotattributes[:smooth] = false + kw_list +end + + +# TODO Move this to api.jl? + +function _recipedata_vector(plt, plotattributes, args) + still_to_process = RecipeData[] + # the grouping mechanism is a recipe on a GroupBy object + # we simply add the GroupBy object to the front of the args list to allow + # the recipe to be applied + if haskey(plotattributes, :group) + args = (_extract_group_attributes(plotattributes[:group], args...), args...) + end + + # if we were passed a vector/matrix of seriestypes and there's more than one row, + # we want to duplicate the inputs, once for each seriestype row. + if !isempty(args) + append!(still_to_process, _expand_seriestype_array(plotattributes, args)) + end + + # remove subplot and axis args from plotattributes... + # they will be passed through in the kw_list + if !isempty(args) + for (k, v) in plotattributes + if is_subplot_attribute(plt, k) || is_axis_attribute(plt, k) + reset_kw!(plotattributes, k) + end + end + end + + still_to_process +end + +function _expand_seriestype_array(plotattributes, args) + sts = get(plotattributes, :seriestype, :path) + if typeof(sts) <: AbstractArray + reset_kw!(plotattributes, :seriestype) + rd = Vector{RecipeData}(undef, size(sts, 1)) + for r in axes(sts, 1) + dc = copy(plotattributes) + dc[:seriestype] = sts[r:r, :] + rd[r] = RecipeData(dc, args) + end + rd + else + RecipeData[RecipeData(copy(plotattributes), args)] + end +end + + +function _finish_userrecipe!(plt, kw_list, recipedata) + # when the arg tuple is empty, that means there's nothing left to recursively + # process... finish up and add to the kw_list + kw = recipedata.plotattributes + preprocess_attributes!(plt, kw) + # if there was a grouping, filter the data here + _filter_input_data!(kw) + process_userrecipe!(plt, kw_list, kw) +end + + +# -------------------------------- +# Fallback user recipes +# -------------------------------- + +# These call `_apply_type_recipe` in type_recipe.jl and finally the `SliceIt` recipe in +# series.jl. + +# handle "type recipes" by converting inputs, and then either re-calling or slicing +@recipe function f(x, y, z) + wrap_surfaces!(plotattributes, x, y, z) + did_replace = false + newx = _apply_type_recipe(plotattributes, x, :x) + x === newx || (did_replace = true) + newy = _apply_type_recipe(plotattributes, y, :y) + y === newy || (did_replace = true) + newz = _apply_type_recipe(plotattributes, z, :z) + z === newz || (did_replace = true) + if did_replace + newx, newy, newz + else + SliceIt, x, y, z + end +end +@recipe function f(x, y) + wrap_surfaces!(plotattributes, x, y) + did_replace = false + newx = _apply_type_recipe(plotattributes, x, :x) + x === newx || (did_replace = true) + newy = _apply_type_recipe(plotattributes, y, :y) + y === newy || (did_replace = true) + if did_replace + newx, newy + else + SliceIt, x, y, nothing + end +end +@recipe function f(y) + wrap_surfaces!(plotattributes, y) + newy = _apply_type_recipe(plotattributes, y, :y) + if y !== newy + newy + else + SliceIt, nothing, y, nothing + end +end + +# if there's more than 3 inputs, it can't be passed directly to SliceIt +# so we'll apply_type_recipe to all of them +@recipe function f(v1, v2, v3, v4, vrest...) + did_replace = false + newargs = map( + v -> begin + newv = _apply_type_recipe(plotattributes, v, :unknown) + if newv !== v + did_replace = true + end + newv + end, + (v1, v2, v3, v4, vrest...), + ) + if !did_replace + error("Couldn't process recipe args: $(map(typeof, (v1, v2, v3, v4, vrest...)))") + end + newargs +end + + +# helper function to ensure relevant attributes are wrapped by Surface +function wrap_surfaces!(plotattributes, args...) end +wrap_surfaces!(plotattributes, x::AMat, y::AMat, z::AMat) = wrap_surfaces!(plotattributes) +wrap_surfaces!(plotattributes, x::AVec, y::AVec, z::AMat) = wrap_surfaces!(plotattributes) +function wrap_surfaces!(plotattributes, x::AVec, y::AVec, z::Surface) + wrap_surfaces!(plotattributes) +end +function wrap_surfaces!(plotattributes) + if haskey(plotattributes, :fill_z) + v = plotattributes[:fill_z] + if !isa(v, Surface) + plotattributes[:fill_z] = Surface(v) + end + end +end + + +# -------------------------------- +# Special Cases +# -------------------------------- + +# -------------------------------- +# 1 argument + +@recipe function f(n::Integer) + if is3d(plotattributes) + SliceIt, n, n, n + else + SliceIt, n, n, nothing + end +end + +# return a surface if this is a 3d plot, otherwise let it be sliced up +@recipe function f(mat::AMat) + if is3d(plotattributes) + n, m = axes(mat) + m, n, Surface(mat) + else + nothing, mat, nothing + end +end + +# if a matrix is wrapped by Formatted, do similar logic, but wrap data with Surface +@recipe function f(fmt::Formatted{<:AMat}) + if is3d(plotattributes) + mat = fmt.data + n, m = axes(mat) + m, n, Formatted(Surface(mat), fmt.formatter) + else + nothing, fmt, nothing + end +end + +# assume this is a Volume, so construct one +@recipe function f(vol::AbstractArray{<:MaybeNumber, 3}, args...) + seriestype := :volume + SliceIt, nothing, Volume(vol, args...), nothing +end + +# Dicts: each entry is a data point (x,y)=(key,value) +@recipe f(d::AbstractDict) = collect(keys(d)), collect(values(d)) + +# function without range... use the current range of the x-axis +@recipe function f(f::FuncOrFuncs{F}) where {F <: Function} + plt = plotattributes[:plot_object] + xmin, xmax = if haskey(plotattributes, :xlims) + plotattributes[:xlims] + else + try + get_axis_limits(plt, :x) + catch + xinv = inverse_scale_func(get(plotattributes, :xscale, :identity)) + xm = PlotUtils.tryrange(f, xinv.([-5, -1, 0, 0.01])) + xm, PlotUtils.tryrange(f, filter(x -> x > xm, xinv.([5, 1, 0.99, 0, -0.01]))) + end + end + f, xmin, xmax +end + + +# -------------------------------- +# 2 arguments + +# if functions come first, just swap the order (not to be confused with parametric +# functions... as there would be more than one function passed in) +@recipe function f(f::FuncOrFuncs{F}, x) where {F <: Function} + F2 = typeof(x) + @assert !(F2 <: Function || (F2 <: AbstractArray && F2.parameters[1] <: Function)) + # otherwise we'd hit infinite recursion here + x, f +end + + +# -------------------------------- +# 3 arguments + +# surface-like... function +@recipe function f(x::AVec, y::AVec, zf::Function) + x, y, Surface(zf, x, y) # TODO: replace with SurfaceFunction when supported +end + +# surface-like... matrix grid +@recipe function f(x::AVec, y::AVec, z::AMat) + if !is_surface(plotattributes) + plotattributes[:seriestype] = :contour + end + x, y, Surface(z) +end + +# parametric functions +# special handling... xmin/xmax with parametric function(s) +@recipe function f(f::Function, xmin::Number, xmax::Number) + xscale, yscale = [get(plotattributes, sym, :identity) for sym in (:xscale, :yscale)] + _scaled_adapted_grid(f, xscale, yscale, xmin, xmax) +end +@recipe function f(fs::AbstractArray{F}, xmin::Number, xmax::Number) where {F <: Function} + xscale, yscale = [get(plotattributes, sym, :identity) for sym in (:xscale, :yscale)] + unzip(_scaled_adapted_grid.(fs, xscale, yscale, xmin, xmax)) +end +@recipe f( + fx::FuncOrFuncs{F}, + fy::FuncOrFuncs{G}, + u::AVec, +) where {F <: Function, G <: Function} = _map_funcs(fx, u), _map_funcs(fy, u) +@recipe f( + fx::FuncOrFuncs{F}, + fy::FuncOrFuncs{G}, + umin::Number, + umax::Number, + n = 200, +) where {F <: Function, G <: Function} = fx, fy, range(umin, stop = umax, length = n) + +function _scaled_adapted_grid(f, xscale, yscale, xmin, xmax) + (xf, xinv), (yf, yinv) = ((scale_func(s), inverse_scale_func(s)) for s in (xscale, yscale)) + xs, ys = PlotUtils.adapted_grid(yf ∘ f ∘ xinv, xf.((xmin, xmax))) + xinv.(xs), yinv.(ys) +end + +# special handling... 3D parametric function(s) +@recipe function f( + fx::FuncOrFuncs{F}, + fy::FuncOrFuncs{G}, + fz::FuncOrFuncs{H}, + u::AVec, +) where {F <: Function, G <: Function, H <: Function} + _map_funcs(fx, u), _map_funcs(fy, u), _map_funcs(fz, u) +end +@recipe function f( + fx::FuncOrFuncs{F}, + fy::FuncOrFuncs{G}, + fz::FuncOrFuncs{H}, + umin::Number, + umax::Number, + numPoints = 200, +) where {F <: Function, G <: Function, H <: Function} + fx, fy, fz, range(umin, stop = umax, length = numPoints) +end + +# list of tuples +@recipe f(v::AVec{<:Tuple}) = unzip(v) +@recipe f(tup::Tuple) = [tup] diff --git a/src/RecipePipeline/utils.jl b/src/RecipePipeline/utils.jl new file mode 100644 index 00000000..54862d47 --- /dev/null +++ b/src/RecipePipeline/utils.jl @@ -0,0 +1,220 @@ +const AVec = AbstractVector +const AMat = AbstractMatrix +const KW = Dict{Symbol, Any} +const AKW = AbstractDict{Symbol, Any} + +# -------------------------------- +# DefaultsDict +# -------------------------------- + +struct DefaultsDict <: AbstractDict{Symbol, Any} + explicit::KW + defaults::KW +end + +function Base.getindex(dd::DefaultsDict, k) + return haskey(dd.explicit, k) ? dd.explicit[k] : dd.defaults[k] +end +Base.haskey(dd::DefaultsDict, k) = haskey(dd.explicit, k) || haskey(dd.defaults, k) +Base.get(dd::DefaultsDict, k, default) = haskey(dd, k) ? dd[k] : default +function Base.get!(dd::DefaultsDict, k, default) + v = if haskey(dd, k) + dd[k] + else + dd.defaults[k] = default + end + return v +end +function Base.delete!(dd::DefaultsDict, k) + haskey(dd.explicit, k) && delete!(dd.explicit, k) + haskey(dd.defaults, k) && delete!(dd.defaults, k) +end +Base.length(dd::DefaultsDict) = length(union(keys(dd.explicit), keys(dd.defaults))) +function Base.iterate(dd::DefaultsDict) + exp_keys = keys(dd.explicit) + def_keys = setdiff(keys(dd.defaults), exp_keys) + key_list = collect(Iterators.flatten((exp_keys, def_keys))) + iterate(dd, (key_list, 1)) +end +function Base.iterate(dd::DefaultsDict, (key_list, i)) + i > length(key_list) && return nothing + k = key_list[i] + (k => dd[k], (key_list, i + 1)) +end + +Base.copy(dd::DefaultsDict) = DefaultsDict(copy(dd.explicit), dd.defaults) + +RecipesBase.is_explicit(dd::DefaultsDict, k) = haskey(dd.explicit, k) +isdefault(dd::DefaultsDict, k) = !is_explicit(dd, k) && haskey(dd.defaults, k) + +Base.setindex!(dd::DefaultsDict, v, k) = dd.explicit[k] = v + +# Reset to default value and return dict +reset_kw!(dd::DefaultsDict, k) = is_explicit(dd, k) ? delete!(dd.explicit, k) : dd +# Reset to default value and return old value +pop_kw!(dd::DefaultsDict, k) = is_explicit(dd, k) ? pop!(dd.explicit, k) : dd.defaults[k] +pop_kw!(dd::DefaultsDict, k, default) = + is_explicit(dd, k) ? pop!(dd.explicit, k) : get(dd.defaults, k, default) +# Fallbacks for dicts without defaults +reset_kw!(d::AKW, k) = delete!(d, k) +pop_kw!(d::AKW, k) = pop!(d, k) +pop_kw!(d::AKW, k, default) = pop!(d, k, default) + + +# -------------------------------- +# 3D types +# -------------------------------- + +abstract type AbstractSurface end + +"represents a contour or surface mesh" +struct Surface{M <: AMat} <: AbstractSurface + surf::M +end + +Surface(f::Function, x, y) = Surface(Float64[f(xi, yi) for yi in y, xi in x]) + +Base.Array(surf::Surface) = surf.surf + +for f in (:length, :size, :axes) + @eval Base.$f(surf::Surface, args...) = $f(surf.surf, args...) +end +Base.copy(surf::Surface) = Surface(copy(surf.surf)) +Base.eltype(surf::Surface{T}) where {T} = eltype(T) + + +struct Volume{T} + v::Array{T, 3} + x_extents::Tuple{T, T} + y_extents::Tuple{T, T} + z_extents::Tuple{T, T} +end + +default_extents(::Type{T}) where {T} = (zero(T), one(T)) + +function Volume( + v::Array{T, 3}, + x_extents = default_extents(T), + y_extents = default_extents(T), + z_extents = default_extents(T), +) where {T} + Volume(v, x_extents, y_extents, z_extents) +end + +Base.Array(vol::Volume) = vol.v +for f in (:length, :size) + @eval Base.$f(vol::Volume, args...) = $f(vol.v, args...) +end +Base.copy(vol::Volume{T}) where {T} = + Volume{T}(copy(vol.v), vol.x_extents, vol.y_extents, vol.z_extents) +Base.eltype(vol::Volume{T}) where {T} = T + + +# -------------------------------- +# Formatting +# -------------------------------- + +"Represents data values with formatting that should apply to the tick labels." +struct Formatted{T} + data::T + formatter::Function +end + +# ------------------------------- +# 3D seriestypes +# ------------------------------- + +# TODO: Move to RecipesBase? +""" + is3d(::Type{Val{:myseriestype}}) + +Returns `true` if `myseriestype` represents a 3D series, `false` otherwise. +""" +is3d(st) = false +for st in ( + :contour, + :contourf, + :contour3d, + :heatmap, + :image, + :path3d, + :scatter3d, + :surface, + :volume, + :wireframe, +) + @eval is3d(::Type{Val{Symbol($(string(st)))}}) = true +end +is3d(st::Symbol) = is3d(Val{st}) +is3d(plt, stv::AbstractArray) = all(st -> is3d(plt, st), stv) +is3d(plotattributes::AbstractDict) = is3d(get(plotattributes, :seriestype, :path)) + + +""" + is_surface(::Type{Val{:myseriestype}}) + +Returns `true` if `myseriestype` represents a surface series, `false` otherwise. +""" +is_surface(st) = false +for st in (:contour, :contourf, :contour3d, :image, :heatmap, :surface, :wireframe) + @eval is_surface(::Type{Val{Symbol($(string(st)))}}) = true +end +is_surface(st::Symbol) = is_surface(Val{st}) +is_surface(plt, stv::AbstractArray) = all(st -> is_surface(plt, st), stv) +is_surface(plotattributes::AbstractDict) = + is_surface(get(plotattributes, :seriestype, :path)) + + +""" + needs_3d_axes(::Type{Val{:myseriestype}}) + +Returns `true` if `myseriestype` needs 3d axes, `false` otherwise. +""" +needs_3d_axes(st) = false +for st in ( + :contour3d, + :path3d, + :scatter3d, + :surface, + :volume, + :wireframe, +) + @eval needs_3d_axes(::Type{Val{Symbol($(string(st)))}}) = true +end +needs_3d_axes(st::Symbol) = needs_3d_axes(Val{st}) +needs_3d_axes(plt, stv::AbstractArray) = all(st -> needs_3d_axes(plt, st), stv) +needs_3d_axes(plotattributes::AbstractDict) = + needs_3d_axes(get(plotattributes, :seriestype, :path)) + + +# -------------------------------- +# Scales +# -------------------------------- + +const SCALE_FUNCTIONS = Dict{Symbol, Function}(:log10 => log10, :log2 => log2, :ln => log) +const INVERSE_SCALE_FUNCTIONS = + Dict{Symbol, Function}(:log10 => exp10, :log2 => exp2, :ln => exp) + +scale_func(scale::Symbol) = x -> get(SCALE_FUNCTIONS, scale, identity)(Float64(x)) +inverse_scale_func(scale::Symbol) = + x -> get(INVERSE_SCALE_FUNCTIONS, scale, identity)(Float64(x)) + + +# -------------------------------- +# Unzip +# -------------------------------- + +for i in 2:4 + @eval begin + unzip(v::AVec{<:Tuple{Vararg{T, $i} where T}}) = + $(Expr(:tuple, (:([t[$j] for t in v]) for j in 1:i)...)) + end +end + + +# -------------------------------- +# Map functions on vectors +# -------------------------------- + +_map_funcs(f::Function, u::AVec) = map(f, u) +_map_funcs(fs::AVec{F}, u::AVec) where {F <: Function} = [map(f, u) for f in fs] diff --git a/src/args.jl b/src/args.jl index 43a0282d..fd3ad325 100644 --- a/src/args.jl +++ b/src/args.jl @@ -86,12 +86,9 @@ const _surface_like = [:contour, :contourf, :contour3d, :heatmap, :surface, :wir like_histogram(seriestype::Symbol) = seriestype in _histogram_like like_line(seriestype::Symbol) = seriestype in _line_like -like_surface(seriestype::Symbol) = seriestype in _surface_like +like_surface(seriestype::Symbol) = is_surface(seriestype) -is3d(seriestype::Symbol) = seriestype in _3dTypes is3d(series::Series) = is3d(series.plotattributes) -is3d(plotattributes::AKW) = trueOrAllTrue(is3d, Symbol(plotattributes[:seriestype])) - is3d(sp::Subplot) = string(sp.attr[:projection]) == "3d" ispolar(sp::Subplot) = string(sp.attr[:projection]) == "polar" ispolar(series::Series) = ispolar(series.plotattributes[:subplot]) @@ -1112,68 +1109,6 @@ function preprocess_attributes!(plotattributes::AKW) return end -# ----------------------------------------------------------------------------- - -"A special type that will break up incoming data into groups, and allow for easier creation of grouped plots" -mutable struct GroupBy - groupLabels::Vector # length == numGroups - groupIds::Vector{Vector{Int}} # list of indices for each group -end - - -# this is when given a vector-type of values to group by -function _extract_group_attributes(v::AVec, args...; legendEntry = string) - groupLabels = sort(collect(unique(v))) - n = length(groupLabels) - if n > 100 - @warn("You created n=$n groups... Is that intended?") - end - groupIds = Vector{Int}[filter(i -> v[i] == glab, eachindex(v)) for glab in groupLabels] - GroupBy(map(legendEntry, groupLabels), groupIds) -end - -legendEntryFromTuple(ns::Tuple) = join(ns, ' ') - -# this is when given a tuple of vectors of values to group by -function _extract_group_attributes(vs::Tuple, args...) - isempty(vs) && return GroupBy([""], [axes(args[1],1)]) - v = map(tuple, vs...) - _extract_group_attributes(v, args...; legendEntry = legendEntryFromTuple) -end - -# allow passing NamedTuples for a named legend entry -legendEntryFromTuple(ns::NamedTuple) = - join(["$k = $v" for (k, v) in pairs(ns)], ", ") - -function _extract_group_attributes(vs::NamedTuple, args...) - isempty(vs) && return GroupBy([""], [axes(args[1],1)]) - v = map(NamedTuple{keys(vs)}∘tuple, values(vs)...) - _extract_group_attributes(v, args...; legendEntry = legendEntryFromTuple) -end - -# expecting a mapping of "group label" to "group indices" -function _extract_group_attributes(idxmap::Dict{T,V}, args...) where {T, V<:AVec{Int}} - groupLabels = sortedkeys(idxmap) - groupIds = Vector{Int}[collect(idxmap[k]) for k in groupLabels] - GroupBy(groupLabels, groupIds) -end - -filter_data(v::AVec, idxfilter::AVec{Int}) = v[idxfilter] -filter_data(v, idxfilter) = v - -function filter_data!(plotattributes::AKW, idxfilter) - for s in (:x, :y, :z) - plotattributes[s] = filter_data(get(plotattributes, s, nothing), idxfilter) - end -end - -function _filter_input_data!(plotattributes::AKW) - idxfilter = pop!(plotattributes, :idxfilter, nothing) - if idxfilter !== nothing - filter_data!(plotattributes, idxfilter) - end -end - # ----------------------------------------------------------------------------- diff --git a/src/axes.jl b/src/axes.jl index 15e93b1f..4069b4e6 100644 --- a/src/axes.jl +++ b/src/axes.jl @@ -17,7 +17,7 @@ function Axis(sp::Subplot, letter::Symbol, args...; kw...) :show => true, # show or hide the axis? (useful for linked subplots) ) - attr = Attr(explicit, _axis_defaults_byletter[letter]) + attr = DefaultsDict(explicit, _axis_defaults_byletter[letter]) # update the defaults attr!(Axis([sp], attr), args...; kw...) @@ -117,33 +117,11 @@ Base.setindex!(axis::Axis, v, ks::Symbol...) = setindex!(axis.plotattributes, v, Base.haskey(axis::Axis, k::Symbol) = haskey(axis.plotattributes, k) ignorenan_extrema(axis::Axis) = (ex = axis[:extrema]; (ex.emin, ex.emax)) - -const _scale_funcs = Dict{Symbol,Function}( - :log10 => log10, - :log2 => log2, - :ln => log, -) -const _inv_scale_funcs = Dict{Symbol,Function}( - :log10 => exp10, - :log2 => exp2, - :ln => exp, -) - -# const _label_func = Dict{Symbol,Function}( -# :log10 => x -> "10^$x", -# :log2 => x -> "2^$x", -# :ln => x -> "e^$x", -# ) - const _label_func = Dict{Symbol,Function}( :log10 => x -> "10^$x", :log2 => x -> "2^$x", :ln => x -> "e^$x", ) - - -scalefunc(scale::Symbol) = x -> get(_scale_funcs, scale, identity)(Float64(x)) -invscalefunc(scale::Symbol) = x -> get(_inv_scale_funcs, scale, identity)(Float64(x)) labelfunc(scale::Symbol, backend::AbstractBackend) = get(_label_func, scale, string) function optimal_ticks_and_labels(sp::Subplot, axis::Axis, ticks = nothing) @@ -151,7 +129,7 @@ function optimal_ticks_and_labels(sp::Subplot, axis::Axis, ticks = nothing) # scale the limits scale = axis[:scale] - sf = scalefunc(scale) + sf = scale_func(scale) # If the axis input was a Date or DateTime use a special logic to find # "round" Date(Time)s as ticks @@ -196,11 +174,11 @@ function optimal_ticks_and_labels(sp::Subplot, axis::Axis, ticks = nothing) # chosen ticks is not too much bigger than amin - amax: strict_span = false, ) - axis[:lims] = map(invscalefunc(scale), (viewmin, viewmax)) + axis[:lims] = map(inverse_scale_func(scale), (viewmin, viewmax)) else scaled_ticks = map(sf, (filter(t -> amin <= t <= amax, ticks))) end - unscaled_ticks = map(invscalefunc(scale), scaled_ticks) + unscaled_ticks = map(inverse_scale_func(scale), scaled_ticks) labels = if any(isfinite, unscaled_ticks) formatter = axis[:formatter] @@ -400,7 +378,7 @@ function expand_extrema!(sp::Subplot, plotattributes::AKW) if fr === nothing && plotattributes[:seriestype] == :bar fr = 0.0 end - if fr !== nothing && !all3D(plotattributes) + if fr !== nothing && !is3d(plotattributes) axis = sp.attr[vert ? :yaxis : :xaxis] if typeof(fr) <: Tuple for fri in fr @@ -445,7 +423,7 @@ end # push the limits out slightly function widen(lmin, lmax, scale = :identity) - f, invf = scalefunc(scale), invscalefunc(scale) + f, invf = scale_func(scale), inverse_scale_func(scale) span = f(lmax) - f(lmin) # eps = NaNMath.max(1e-16, min(1e-2span, 1e-10)) eps = NaNMath.max(1e-16, 0.03span) @@ -648,8 +626,8 @@ function axis_drawing_info(sp::Subplot) sp[:framestyle] in (:semi, :box) && push!(xborder_segs, (xmin, y2), (xmax, y2)) # top spine end if !(xaxis[:ticks] in (:none, nothing, false)) - f = scalefunc(yaxis[:scale]) - invf = invscalefunc(yaxis[:scale]) + f = scale_func(yaxis[:scale]) + invf = inverse_scale_func(yaxis[:scale]) tick_start, tick_stop = if sp[:framestyle] == :origin t = invf(f(0) + 0.012 * (f(ymax) - f(ymin))) (-t, t) @@ -702,8 +680,8 @@ function axis_drawing_info(sp::Subplot) sp[:framestyle] in (:semi, :box) && push!(yborder_segs, (x2, ymin), (x2, ymax)) # right spine end if !(yaxis[:ticks] in (:none, nothing, false)) - f = scalefunc(xaxis[:scale]) - invf = invscalefunc(xaxis[:scale]) + f = scale_func(xaxis[:scale]) + invf = inverse_scale_func(xaxis[:scale]) tick_start, tick_stop = if sp[:framestyle] == :origin t = invf(f(0) + 0.012 * (f(xmax) - f(xmin))) (-t, t) @@ -794,8 +772,8 @@ function axis_drawing_info_3d(sp::Subplot) sp[:framestyle] in (:semi, :box) && push!(xborder_segs, (xmin, y2, z2), (xmax, y2, z2)) # top spine end if !(xaxis[:ticks] in (:none, nothing, false)) - f = scalefunc(yaxis[:scale]) - invf = invscalefunc(yaxis[:scale]) + f = scale_func(yaxis[:scale]) + invf = inverse_scale_func(yaxis[:scale]) tick_start, tick_stop = if sp[:framestyle] == :origin t = invf(f(0) + 0.012 * (f(ymax) - f(ymin))) (-t, t) @@ -869,8 +847,8 @@ function axis_drawing_info_3d(sp::Subplot) sp[:framestyle] in (:semi, :box) && push!(yborder_segs, (x2, ymin, z2), (x2, ymax, z2)) # right spine end if !(yaxis[:ticks] in (:none, nothing, false)) - f = scalefunc(xaxis[:scale]) - invf = invscalefunc(xaxis[:scale]) + f = scale_func(xaxis[:scale]) + invf = inverse_scale_func(xaxis[:scale]) tick_start, tick_stop = if sp[:framestyle] == :origin t = invf(f(0) + 0.012 * (f(xmax) - f(xmin))) (-t, t) @@ -944,8 +922,8 @@ function axis_drawing_info_3d(sp::Subplot) sp[:framestyle] in (:semi, :box) && push!(zborder_segs, (x2, y2, zmin), (x2, y2, zmax)) end if !(zaxis[:ticks] in (:none, nothing, false)) - f = scalefunc(xaxis[:scale]) - invf = invscalefunc(xaxis[:scale]) + f = scale_func(xaxis[:scale]) + invf = inverse_scale_func(xaxis[:scale]) tick_start, tick_stop = if sp[:framestyle] == :origin t = invf(f(0) + 0.012 * (f(ymax) - f(ymin))) (-t, t) diff --git a/src/backends/hdf5.jl b/src/backends/hdf5.jl index 677d994e..4f38ee0d 100644 --- a/src/backends/hdf5.jl +++ b/src/backends/hdf5.jl @@ -83,7 +83,7 @@ if length(HDF5PLOT_MAP_TELEM2STR) < 1 "ARRAY" => Array, #Dict won't allow Array to be key in HDF5PLOT_MAP_TELEM2STR #Sub-structure types: - "ATTR" => Attr, + "DEFAULTSDICT" => DefaultsDict, "FONT" => Font, "BOUNDINGBOX" => BoundingBox, "GRIDLAYOUT" => GridLayout, @@ -395,12 +395,12 @@ function _hdf5plot_write(grp, plotattributes::KW) end return end -function _hdf5plot_write(grp, plotattributes::Attr) +function _hdf5plot_write(grp, plotattributes::DefaultsDict) for (k, v) in plotattributes kstr = string(k) _hdf5plot_gwrite(grp, kstr, v) end - _hdf5plot_writetype(grp, Attr) + _hdf5plot_writetype(grp, DefaultsDict) end @@ -558,14 +558,14 @@ parent = RootLayout() return GridLayout(parent, minpad, bbox, grid, widths, heights, attr) end -function _hdf5plot_read(grp, T::Type{Attr}) - attr = Attr(KW(), _plot_defaults) +function _hdf5plot_read(grp, T::Type{DefaultsDict}) + attr = DefaultsDict(KW(), _plot_defaults) v = _hdf5plot_read(grp, attr) return attr end function _hdf5plot_read(grp, k::String, T::Type{Axis}) grp = HDF5.g_open(grp, k) - plotattributes = Attr(KW(), _plot_defaults) + plotattributes = DefaultsDict(KW(), _plot_defaults) _hdf5plot_read(grp, plotattributes) return Axis([], plotattributes) end @@ -610,7 +610,7 @@ function _hdf5plot_readattr(grp, plotattributes::AbstractDict) return end _hdf5plot_read(grp, plotattributes::KW) = _hdf5plot_readattr(grp, plotattributes) -_hdf5plot_read(grp, plotattributes::Attr) = _hdf5plot_readattr(grp, plotattributes) +_hdf5plot_read(grp, plotattributes::DefaultsDict) = _hdf5plot_readattr(grp, plotattributes) # Read main plot structures: # ---------------------------------------------------------------- @@ -623,7 +623,7 @@ function _hdf5plot_read(sp::Subplot, subpath::String, f) for i in 1:nseries grp = HDF5.g_open(f, _hdf5_plotelempath("$subpath/series_list/series$i")) - seriesinfo = Attr(KW(), _plot_defaults) + seriesinfo = DefaultsDict(KW(), _plot_defaults) _hdf5plot_read(grp, seriesinfo) plot!(sp, seriesinfo[:x], seriesinfo[:y]) #Add data & create data structures _hdf5_merge!(sp.series_list[end].plotattributes, seriesinfo) @@ -631,7 +631,7 @@ function _hdf5plot_read(sp::Subplot, subpath::String, f) #Perform after adding series... otherwise values get overwritten: grp = HDF5.g_open(f, _hdf5_plotelempath("$subpath/attr")) - attr = Attr(KW(), _plot_defaults) + attr = DefaultsDict(KW(), _plot_defaults) _hdf5plot_read(grp, attr) _hdf5_merge!(sp.attr, attr) diff --git a/src/components.jl b/src/components.jl index 6aefa267..fa657a4d 100644 --- a/src/components.jl +++ b/src/components.jl @@ -595,8 +595,8 @@ function process_annotation(sp::Subplot, xs, ys, labs, font = font()) ylength = length(methods(length, (typeof(ys),))) == 0 ? 1 : length(ys) for i in 1:max(xlength, ylength, length(labs)) x, y, lab = _cycle(xs, i), _cycle(ys, i), _cycle(labs, i) - x = typeof(x) <: TimeType ? Dates.value(x) : x - y = typeof(y) <: TimeType ? Dates.value(y) : y + x = typeof(x) <: TimeType ? Dates.value(x) : x + y = typeof(y) <: TimeType ? Dates.value(y) : y if lab == :auto alphabet = "abcdefghijklmnopqrstuvwxyz" push!(anns, (x, y, text(string("(", alphabet[sp[:subplot_index]], ")"), font))) @@ -652,23 +652,6 @@ end # ----------------------------------------------------------------------- -abstract type AbstractSurface end - -"represents a contour or surface mesh" -struct Surface{M<:AMat} <: AbstractSurface - surf::M -end - -Surface(f::Function, x, y) = Surface(Float64[f(xi,yi) for yi in y, xi in x]) - -Base.Array(surf::Surface) = surf.surf - -for f in (:length, :size, :axes) - @eval Base.$f(surf::Surface, args...) = $f(surf.surf, args...) -end -Base.copy(surf::Surface) = Surface(copy(surf.surf)) -Base.eltype(surf::Surface{T}) where {T} = eltype(T) - function expand_extrema!(a::Axis, surf::Surface) ex = a[:extrema] for vi in surf.surf @@ -688,28 +671,7 @@ end # # I don't want to clash with ValidatedNumerics, but this would be nice: # ..(a::T, b::T) = (a,b) -struct Volume{T} - v::Array{T,3} - x_extents::Tuple{T,T} - y_extents::Tuple{T,T} - z_extents::Tuple{T,T} -end -default_extents(::Type{T}) where {T} = (zero(T), one(T)) - -function Volume(v::Array{T,3}, - x_extents = default_extents(T), - y_extents = default_extents(T), - z_extents = default_extents(T)) where T - Volume(v, x_extents, y_extents, z_extents) -end - -Base.Array(vol::Volume) = vol.v -for f in (:length, :size) - @eval Base.$f(vol::Volume, args...) = $f(vol.v, args...) -end -Base.copy(vol::Volume{T}) where {T} = Volume{T}(copy(vol.v), vol.x_extents, vol.y_extents, vol.z_extents) -Base.eltype(vol::Volume{T}) where {T} = T # ----------------------------------------------------------------------- @@ -773,14 +735,6 @@ function add_arrows(func::Function, x::AVec, y::AVec) end end -# ----------------------------------------------------------------------- - -"Represents data values with formatting that should apply to the tick labels." -struct Formatted{T} - data::T - formatter::Function -end - # ----------------------------------------------------------------------- "create a BezierCurve for plotting" mutable struct BezierCurve{T <: GeometryTypes.Point} diff --git a/src/pipeline.jl b/src/pipeline.jl index b42303f2..d24c139d 100644 --- a/src/pipeline.jl +++ b/src/pipeline.jl @@ -1,127 +1,74 @@ -# Error for aliases used in recipes -function warn_on_recipe_aliases!(plotattributes, recipe_type, args...) +# RecipePipeline API + +## Warnings + +function RecipePipeline.warn_on_recipe_aliases!( + plt::Plot, + plotattributes, + recipe_type, + args..., +) for k in keys(plotattributes) if !is_default_attribute(k) dk = get(_keyAliases, k, k) if k !== dk - @warn "Attribute alias `$k` detected in the $recipe_type recipe defined for the signature $(signature_string(Val{recipe_type}, args...)). To ensure expected behavior it is recommended to use the default attribute `$dk`." + @warn "Attribute alias `$k` detected in the $recipe_type recipe defined for the signature $(_signature_string(Val{recipe_type}, args...)). To ensure expected behavior it is recommended to use the default attribute `$dk`." end plotattributes[dk] = pop_kw!(plotattributes, k) end end end -function warn_on_recipe_aliases!(v::AbstractVector, recipe_type, args...) - foreach(x -> warn_on_recipe_aliases!(x, recipe_type, args...), v) +function RecipePipeline.warn_on_recipe_aliases!( + plt::Plot, + v::AbstractVector, + recipe_type, + args..., +) + foreach(x -> RecipePipeline.warn_on_recipe_aliases!(plt, x, recipe_type, args...), v) end -function warn_on_recipe_aliases!(rd::RecipeData, recipe_type, args...) - warn_on_recipe_aliases!(rd.plotattributes, recipe_type, args...) +function RecipePipeline.warn_on_recipe_aliases!( + plt::Plot, + rd::RecipeData, + recipe_type, + args..., +) + RecipePipeline.warn_on_recipe_aliases!(plt, rd.plotattributes, recipe_type, args...) end -function signature_string(::Type{Val{:user}}, args...) +function _signature_string(::Type{Val{:user}}, args...) return string("(::", join(string.(typeof.(args)), ", ::"), ")") end -signature_string(::Type{Val{:type}}, T) = "(::Type{$T}, ::$T)" -signature_string(::Type{Val{:plot}}, st) = "(::Type{Val{:$st}}, ::AbstractPlot)" -signature_string(::Type{Val{:series}}, st) = "(::Type{Val{:$st}}, x, y, z)" +_signature_string(::Type{Val{:type}}, T) = "(::Type{$T}, ::$T)" +_signature_string(::Type{Val{:plot}}, st) = "(::Type{Val{:$st}}, ::AbstractPlot)" +_signature_string(::Type{Val{:series}}, st) = "(::Type{Val{:$st}}, x, y, z)" -# ------------------------------------------------------------------ -# preprocessing -function series_idx(kw_list::AVec{KW}, kw::AKW) - Int(kw[:series_plotindex]) - Int(kw_list[1][:series_plotindex]) + 1 +## Grouping + +RecipePipeline.splittable_attribute(plt::Plot, key, val::SeriesAnnotations, len) = + RecipePipeline.splittable_attribute(plt, key, val.strs, len) + +function RecipePipeline.split_attribute(plt::Plot, key, val::SeriesAnnotations, indices) + split_strs = _RecipePipeline.split_attribute(key, val.strs, indices) + return SeriesAnnotations(split_strs, val.font, val.baseshape, val.scalefactor) end -function _expand_seriestype_array(plotattributes::AKW, args) - sts = get(plotattributes, :seriestype, :path) - if typeof(sts) <: AbstractArray - reset_kw!(plotattributes, :seriestype) - rd = Vector{RecipeData}(undef, size(sts, 1)) - for r in axes(sts, 1) - dc = copy(plotattributes) - dc[:seriestype] = sts[r:r, :] - rd[r] = RecipeData(dc, args) - end - rd - else - RecipeData[RecipeData(copy(plotattributes), args)] - end -end -function _preprocess_args(plotattributes::AKW, args, still_to_process::Vector{RecipeData}) - # the grouping mechanism is a recipe on a GroupBy object - # we simply add the GroupBy object to the front of the args list to allow - # the recipe to be applied - if haskey(plotattributes, :group) - args = (_extract_group_attributes(plotattributes[:group], args...), args...) - end +## Preprocessing attributes - # if we were passed a vector/matrix of seriestypes and there's more than one row, - # we want to duplicate the inputs, once for each seriestype row. - if !isempty(args) - append!(still_to_process, _expand_seriestype_array(plotattributes, args)) - end +RecipePipeline.preprocess_attributes!(plt::Plot, plotattributes) = + preprocess_attributes!(plotattributes) # in src/args.jl - # remove subplot and axis args from plotattributes... - # they will be passed through in the kw_list - if !isempty(args) - for (k, v) in plotattributes - if k in _all_subplot_args || k in _all_axis_args - reset_kw!(plotattributes, k) - end - end - end +RecipePipeline.is_axis_attribute(plt::Plot, attr) = is_axis_attr_noletter(attr) # in src/args.jl - args -end - -# ------------------------------------------------------------------ -# user recipes +RecipePipeline.is_subplot_attribute(plt::Plot, attr) = is_subplot_attr(attr) # in src/args.jl -function _process_userrecipes(plt::Plot, plotattributes::AKW, args) - still_to_process = RecipeData[] - args = _preprocess_args(plotattributes, args, still_to_process) +## User recipes - # for plotting recipes, swap out the args and update the parameter dictionary - # we are keeping a stack of series that still need to be processed. - # each pass through the loop, we pop one off and apply the recipe. - # the recipe will return a list a Series objects... the ones that are - # finished (no more args) get added to the kw_list, the ones that are not - # are placed on top of the stack and are then processed further. - kw_list = KW[] - while !isempty(still_to_process) - # grab the first in line to be processed and either add it to the kw_list or - # pass it through apply_recipe to generate a list of RecipeData objects - # (data + attributes) for further processing. - next_series = popfirst!(still_to_process) - # recipedata should be of type RecipeData. - # if it's not then the inputs must not have been fully processed by recipes - if !(typeof(next_series) <: RecipeData) - error("Inputs couldn't be processed... expected RecipeData but got: $next_series") - end - if isempty(next_series.args) - _process_userrecipe(plt, kw_list, next_series) - else - rd_list = - RecipesBase.apply_recipe(next_series.plotattributes, next_series.args...) - warn_on_recipe_aliases!(rd_list, :user, next_series.args...) - prepend!(still_to_process, rd_list) - end - end - - # don't allow something else to handle it - plotattributes[:smooth] = false - kw_list -end - -function _process_userrecipe(plt::Plot, kw_list::Vector{KW}, recipedata::RecipeData) - # when the arg tuple is empty, that means there's nothing left to recursively - # process... finish up and add to the kw_list - kw = recipedata.plotattributes - preprocess_attributes!(kw) +function RecipePipeline.process_userrecipe!(plt::Plot, kw_list, kw) _preprocess_userrecipe(kw) warn_on_unsupported_scales(plt.backend, kw) - # add the plot index plt.n += 1 kw[:series_plotindex] = plt.n @@ -135,9 +82,6 @@ end function _preprocess_userrecipe(kw::AKW) _add_markershape(kw) - # if there was a grouping, filter the data here - _filter_input_data!(kw) - # map marker_z if it's a Function if isa(get(kw, :marker_z, nothing), Function) # TODO: should this take y and/or z as arguments? @@ -197,50 +141,23 @@ function _add_smooth_kw(kw_list::Vector{KW}, kw::AKW) end end -# ------------------------------------------------------------------ -# plot recipes -# Grab the first in line to be processed and pass it through apply_recipe -# to generate a list of RecipeData objects (data + attributes). -# If we applied a "plot recipe" without error, then add the returned datalist's KWs, -# otherwise we just add the original KW. -function _process_plotrecipe( - plt::Plot, - kw::AKW, - kw_list::Vector{KW}, - still_to_process::Vector{KW}, -) - if !isa(get(kw, :seriestype, nothing), Symbol) - # seriestype was never set, or it's not a Symbol, so it can't be a plot recipe - push!(kw_list, kw) - return - end - try - st = kw[:seriestype] - st = kw[:seriestype] = get(_typeAliases, st, st) - datalist = RecipesBase.apply_recipe(kw, Val{st}, plt) - warn_on_recipe_aliases!(datalist, :plot, st) - for data in datalist - preprocess_attributes!(data.plotattributes) - if data.plotattributes[:seriestype] == st - error("Plot recipe $st returned the same seriestype: $(data.plotattributes)") - end - push!(still_to_process, data.plotattributes) - end - catch err - if isa(err, MethodError) - push!(kw_list, kw) - else - rethrow() - end - end - return +RecipePipeline.get_axis_limits(plt::Plot, f, letter) = axis_limits(plt[1], :x) + + +## Plot recipes + +RecipePipeline.type_alias(plt::Plot) = get(_typeAliases, st, st) + + +## Plot setup + +function RecipePipeline.plot_setup!(plt::Plot, plotattributes, kw_list) + _plot_setup(plt, plotattributes, kw_list) + _subplot_setup(plt, plotattributes, kw_list) end - -# ------------------------------------------------------------------ -# setup plot and subplot - +# TODO: Should some of this logic be moved to RecipePipeline? function _plot_setup(plt::Plot, plotattributes::AKW, kw_list::Vector{KW}) # merge in anything meant for the Plot for kw in kw_list, (k, v) in kw @@ -345,6 +262,31 @@ function _subplot_setup(plt::Plot, plotattributes::AKW, kw_list::Vector{KW}) link_axes!(plt.layout, plt[:link]) end +function series_idx(kw_list::AVec{KW}, kw::AKW) + Int(kw[:series_plotindex]) - Int(kw_list[1][:series_plotindex]) + 1 +end + + +## Series recipes + +function RecipePipeline.slice_series_attributes!(plt::Plot, kw_list, kw) + sp::Subplot = kw[:subplot] + # in series attributes given as vector with one element per series, + # select the value for current series + _slice_series_args!(kw, plt, sp, series_idx(kw_list, kw)) +end + +RecipePipeline.series_defaults(plt::Plot) = _series_defaults # in args.jl + +RecipePipeline.is_seriestype_supported(plt::Plot, st) = is_seriestype_supported(st) + +function RecipePipeline.add_series!(plt::Plot, plotattributes) + sp = _prepare_subplot(plt, plotattributes) + _expand_subplot_extrema(sp, plotattributes, plotattributes[:seriestype]) + _update_series_attributes!(plotattributes, plt, sp) + _add_the_series(plt, sp, plotattributes) +end + # getting ready to add the series... last update to subplot from anything # that might have been added during series recipes function _prepare_subplot(plt::Plot{T}, plotattributes::AKW) where {T} @@ -356,7 +298,7 @@ function _prepare_subplot(plt::Plot{T}, plotattributes::AKW) where {T} st = _override_seriestype_check(plotattributes, st) # change to a 3d projection for this subplot? - if is3d(st) + if needs_3d_axes(st) sp.attr[:projection] = "3d" end @@ -368,9 +310,6 @@ function _prepare_subplot(plt::Plot{T}, plotattributes::AKW) where {T} sp end -# ------------------------------------------------------------------ -# series types - function _override_seriestype_check(plotattributes::AKW, st::Symbol) # do we want to override the series type? if !is3d(st) && !(st in (:contour, :contour3d)) @@ -409,48 +348,3 @@ function _add_the_series(plt, sp, plotattributes) push!(sp.series_list, series) _series_added(plt, series) end - -# ------------------------------------------------------------------------------- - -# this method recursively applies series recipes when the seriestype is not supported -# natively by the backend -function _process_seriesrecipe(plt::Plot, plotattributes::AKW) - # replace seriestype aliases - st = Symbol(plotattributes[:seriestype]) - st = plotattributes[:seriestype] = get(_typeAliases, st, st) - - # shapes shouldn't have fillrange set - if plotattributes[:seriestype] == :shape - plotattributes[:fillrange] = nothing - end - - # if it's natively supported, finalize processing and pass along to the backend, - # otherwise recurse - if is_seriestype_supported(st) - sp = _prepare_subplot(plt, plotattributes) - _expand_subplot_extrema(sp, plotattributes, st) - _update_series_attributes!(plotattributes, plt, sp) - _add_the_series(plt, sp, plotattributes) - - else - # get a sub list of series for this seriestype - x, y, z = plotattributes[:x], plotattributes[:y], plotattributes[:z] - datalist = RecipesBase.apply_recipe(plotattributes, Val{st}, x, y, z) - warn_on_recipe_aliases!(datalist, :series, st) - - # assuming there was no error, recursively apply the series recipes - for data in datalist - if isa(data, RecipeData) - preprocess_attributes!(data.plotattributes) - if data.plotattributes[:seriestype] == st - error("The seriestype didn't change in series recipe $st. This will cause a StackOverflow.") - end - _process_seriesrecipe(plt, data.plotattributes) - else - @warn("Unhandled recipe: $(data)") - break - end - end - end - nothing -end diff --git a/src/plot.jl b/src/plot.jl index 13653fee..eb91d772 100644 --- a/src/plot.jl +++ b/src/plot.jl @@ -163,87 +163,11 @@ end # this is the core plotting function. recursively apply recipes to build # a list of series KW dicts. # note: at entry, we only have those preprocessed args which were passed in... no default values yet -function _plot!(plt::Plot, plotattributes::AKW, args::Tuple) - plotattributes[:plot_object] = plt - - if !isempty(args) && !isdefined(Main, :StatsPlots) && - first(split(string(typeof(args[1])), ".")) == "DataFrames" - @warn("You're trying to plot a DataFrame, but this functionality is provided by StatsPlots") - end - - # -------------------------------- - # "USER RECIPES" - # -------------------------------- - - kw_list = _process_userrecipes(plt, plotattributes, args) - - # @info(1) - # map(DD, kw_list) - - - # -------------------------------- - # "PLOT RECIPES" - # -------------------------------- - - # "plot recipe", which acts like a series type, and is processed before - # the plot layout is created, which allows for setting layouts and other plot-wide attributes. - # we get inputs which have been fully processed by "user recipes" and "type recipes", - # so we can expect standard vectors, surfaces, etc. No defaults have been set yet. - still_to_process = kw_list - kw_list = KW[] - while !isempty(still_to_process) - next_kw = popfirst!(still_to_process) - _process_plotrecipe(plt, next_kw, kw_list, still_to_process) - end - - # @info(2) - # map(DD, kw_list) - - # -------------------------------- - # Plot/Subplot/Layout setup - # -------------------------------- - _plot_setup(plt, plotattributes, kw_list) - _subplot_setup(plt, plotattributes, kw_list) - - # !!! note: At this point, kw_list is fully decomposed into individual series... one KW per series. !!! - # !!! The next step is to recursively apply series recipes until the backend supports that series type !!! - - # -------------------------------- - # "SERIES RECIPES" - # -------------------------------- - - # @info(3) - # map(DD, kw_list) - - for kw in kw_list - sp::Subplot = kw[:subplot] - - # in series attributes given as vector with one element per series, - # select the value for current series - _slice_series_args!(kw, plt, sp, series_idx(kw_list,kw)) - - - series_attr = Attr(kw, _series_defaults) - # now we have a fully specified series, with colors chosen. we must recursively handle - # series recipes, which dispatch on seriestype. If a backend does not natively support a seriestype, - # we check for a recipe that will convert that series type into one made up of lower-level components. - # For example, a histogram is just a bar plot with binned data, a bar plot is really a filled step plot, - # and a step plot is really just a path. So any backend that supports drawing a path will implicitly - # be able to support step, bar, and histogram plots (and any recipes that use those components). - _process_seriesrecipe(plt, series_attr) - end - - # -------------------------------- - +function _plot!(plt::Plot, plotattributes, args) + RecipePipeline.recipe_pipeline!(plt, plotattributes, args) current(plt) - - # do we want to force display? - # if plt[:show] - # gui(plt) - # end _do_plot_show(plt, plt[:show]) - - plt + return plt end diff --git a/src/recipes.jl b/src/recipes.jl index 79b4176d..a0031712 100644 --- a/src/recipes.jl +++ b/src/recipes.jl @@ -457,6 +457,8 @@ end () end @deps plots_heatmap shape +is_3d(::Type{Val{:plots_heatmap}}) = true +is_surface(::Type{Val{:plots_heatmap}}) = true # --------------------------------------------------------------------------- # Histograms diff --git a/src/series.jl b/src/series.jl index fd97c4ee..cfc1cc7a 100644 --- a/src/series.jl +++ b/src/series.jl @@ -1,403 +1,7 @@ - - -# create a new "build_series_args" which converts all inputs into xs = Any[xitems], ys = Any[yitems]. -# Special handling for: no args, xmin/xmax, parametric, dataframes -# Then once inputs have been converted, build the series args, map functions, etc. -# This should cut down on boilerplate code and allow more focused dispatch on type -# note: returns meta information... mainly for use with automatic labeling from DataFrames for now - -const FuncOrFuncs{F} = Union{F, Vector{F}, Matrix{F}} -const MaybeNumber = Union{Number, Missing} -const MaybeString = Union{AbstractString, Missing} -const DataPoint = Union{MaybeNumber, MaybeString} - -prepare_series_data(x) = error("Cannot convert $(typeof(x)) to series data for plotting") -prepare_series_data(::Nothing) = nothing -prepare_series_data(t::Tuple{T, T}) where {T <: Number} = t -prepare_series_data(f::Function) = f -prepare_series_data(ar::AbstractRange{<:Number}) = ar -function prepare_series_data(a::AbstractArray{<:MaybeNumber}) - f = isimmutable(a) ? replace : replace! - a = f(x -> ismissing(x) || isinf(x) ? NaN : x, map(float, a)) -end -prepare_series_data(a::AbstractArray{<:Missing}) = fill(NaN, axes(a)) -prepare_series_data(a::AbstractArray{<:MaybeString}) = - replace(x -> ismissing(x) ? "" : x, a) -prepare_series_data(s::Surface{<:AMat{<:MaybeNumber}}) = - Surface(prepare_series_data(s.surf)) -prepare_series_data(s::Surface) = s # non-numeric Surface, such as an image -prepare_series_data(v::Volume) = - Volume(prepare_series_data(v.v), v.x_extents, v.y_extents, v.z_extents) - -# default: assume x represents a single series -series_vector(x, plotattributes) = [prepare_series_data(x)] - -# fixed number of blank series -series_vector(n::Integer, plotattributes) = [zeros(0) for i in 1:n] - -# vector of data points is a single series -series_vector(v::AVec{<:DataPoint}, plotattributes) = [prepare_series_data(v)] - -# list of things (maybe other vectors, functions, or something else) -function series_vector(v::AVec, plotattributes) - if all(x -> x isa MaybeNumber, v) - series_vector(Vector{MaybeNumber}(v), plotattributes) - elseif all(x -> x isa MaybeString, v) - series_vector(Vector{MaybeString}(v), plotattributes) - else - vcat((series_vector(vi, plotattributes) for vi in v)...) - end -end - -# Matrix is split into columns -function series_vector(v::AMat{<:DataPoint}, plotattributes) - if all3D(plotattributes) - [prepare_series_data(Surface(v))] - else - [prepare_series_data(v[:, i]) for i in axes(v, 2)] - end -end - -# -------------------------------------------------------------------- -# Fillranges & ribbons - - -process_fillrange(range::Number, plotattributes) = [range] -process_fillrange(range, plotattributes) = series_vector(range, plotattributes) - -process_ribbon(ribbon::Number, plotattributes) = [ribbon] -process_ribbon(ribbon, plotattributes) = series_vector(ribbon, plotattributes) -# ribbon as a tuple: (lower_ribbons, upper_ribbons) -process_ribbon(ribbon::Tuple{S, T}, plotattributes) where {S, T} = collect(zip( - series_vector(ribbon[1], plotattributes), - series_vector(ribbon[2], plotattributes), -)) - - -# -------------------------------------------------------------------- - -compute_x(x::Nothing, y::Nothing, z) = axes(z, 1) -compute_x(x::Nothing, y, z) = axes(y, 1) -compute_x(x::Function, y, z) = map(x, y) -compute_x(x, y, z) = x - -compute_y(x::Nothing, y::Nothing, z) = axes(z, 2) -compute_y(x, y::Function, z) = map(y, x) -compute_y(x, y, z) = y - -compute_z(x, y, z::Function) = map(z, x, y) -compute_z(x, y, z::AbstractMatrix) = Surface(z) -compute_z(x, y, z::Nothing) = nothing -compute_z(x, y, z) = z - -nobigs(v::AVec{BigFloat}) = map(Float64, v) -nobigs(v::AVec{BigInt}) = map(Int64, v) -nobigs(v) = v - -@noinline function compute_xyz(x, y, z) - x = compute_x(x, y, z) - y = compute_y(x, y, z) - z = compute_z(x, y, z) - nobigs(x), nobigs(y), nobigs(z) -end - -# not allowed -compute_xyz(x::Nothing, y::FuncOrFuncs{F}, z) where {F <: Function} = - error("If you want to plot the function `$y`, you need to define the x values!") -compute_xyz(x::Nothing, y::Nothing, z::FuncOrFuncs{F}) where {F <: Function} = - error("If you want to plot the function `$z`, you need to define x and y values!") -compute_xyz(x::Nothing, y::Nothing, z::Nothing) = error("x/y/z are all nothing!") - -# -------------------------------------------------------------------- - - -# we are going to build recipes to do the processing and splitting of the args - -# -------------------------------------------------------------------- -# The catch-all SliceIt recipe -# -------------------------------------------------------------------- - -# ensure we dispatch to the slicer -struct SliceIt end - -# The `SliceIt` recipe finishes user and type recipe processing. -# It splits processed data into individual series data, stores in copied `plotattributes` -# for each series and returns no arguments. -@recipe function f(::Type{SliceIt}, x, y, z) - - # handle data with formatting attached - if typeof(x) <: Formatted - xformatter := x.formatter - x = x.data - end - if typeof(y) <: Formatted - yformatter := y.formatter - y = y.data - end - if typeof(z) <: Formatted - zformatter := z.formatter - z = z.data - end - - xs = series_vector(x, plotattributes) - ys = series_vector(y, plotattributes) - zs = series_vector(z, plotattributes) - - fr = pop!(plotattributes, :fillrange, nothing) - fillranges = process_fillrange(fr, plotattributes) - mf = length(fillranges) - - rib = pop!(plotattributes, :ribbon, nothing) - ribbons = process_ribbon(rib, plotattributes) - mr = length(ribbons) - - mx = length(xs) - my = length(ys) - mz = length(zs) - if mx > 0 && my > 0 && mz > 0 - for i in 1:max(mx, my, mz) - # add a new series - di = copy(plotattributes) - xi, yi, zi = xs[mod1(i, mx)], ys[mod1(i, my)], zs[mod1(i, mz)] - di[:x], di[:y], di[:z] = compute_xyz(xi, yi, zi) - - # handle fillrange - fr = fillranges[mod1(i, mf)] - di[:fillrange] = isa(fr, Function) ? map(fr, di[:x]) : fr - - # handle ribbons - rib = ribbons[mod1(i, mr)] - di[:ribbon] = isa(rib, Function) ? map(rib, di[:x]) : rib - - push!(series_list, RecipeData(di, ())) - end - end - nothing # don't add a series for the main block -end - - -# -------------------------------------------------------------------- -# Apply type recipes -# -------------------------------------------------------------------- - -# this is the default "type recipe"... just pass the object through -@recipe f(::Type{T}, v::T) where {T} = v - -# this should catch unhandled "series recipes" and error with a nice message -@recipe f(::Type{V}, x, y, z) where {V <: Val} = - error("The backend must not support the series type $V, and there isn't a series recipe defined.") - -function _apply_type_recipe(plotattributes, v, letter) - _preprocess_axis_args!(plotattributes, letter) - rdvec = RecipesBase.apply_recipe(plotattributes, typeof(v), v) - warn_on_recipe_aliases!(plotattributes, :type, typeof(v)) - _postprocess_axis_args!(plotattributes, letter) - return rdvec[1].args[1] -end - -# Handle type recipes when the recipe is defined on the elements. -# This sort of recipe should return a pair of functions... one to convert to number, -# and one to format tick values. -function _apply_type_recipe(plotattributes, v::AbstractArray, letter) - _preprocess_axis_args!(plotattributes, letter) - # First we try to apply an array type recipe. - w = RecipesBase.apply_recipe(plotattributes, typeof(v), v)[1].args[1] - warn_on_recipe_aliases!(plotattributes, :type, typeof(v)) - # If the type did not change try it element-wise - if typeof(v) == typeof(w) - isempty(skipmissing(v)) && return Float64[] - x = first(skipmissing(v)) - args = RecipesBase.apply_recipe(plotattributes, typeof(x), x)[1].args - warn_on_recipe_aliases!(plotattributes, :type, typeof(x)) - _postprocess_axis_args!(plotattributes, letter) - if length(args) == 2 && all(arg -> arg isa Function, args) - numfunc, formatter = args - return Formatted(map(numfunc, v), formatter) - else - return v - end - end - _postprocess_axis_args!(plotattributes, letter) - return w -end - -# special handling for Surface... need to properly unwrap and re-wrap -_apply_type_recipe(plotattributes, v::Surface{<:AMat{<:DataPoint}}) = v -function _apply_type_recipe(plotattributes, v::Surface) - ret = _apply_type_recipe(plotattributes, v.surf) - if typeof(ret) <: Formatted - Formatted(Surface(ret.data), ret.formatter) - else - Surface(ret.data) - end -end - -# don't do anything for datapoints or nothing -_apply_type_recipe(plotattributes, v::AbstractArray{<:DataPoint}, letter) = v -_apply_type_recipe(plotattributes, v::Nothing, letter) = v - -# axis args before type recipes should still be mapped to all axes -function _preprocess_axis_args!(plotattributes) - for (k, v) in plotattributes - if is_axis_attr_noletter(k) - pop!(plotattributes, k) - for l in (:x, :y, :z) - lk = Symbol(l, k) - haskey(plotattributes, lk) || (plotattributes[lk] = v) - end - end - end -end -function _preprocess_axis_args!(plotattributes, letter) - plotattributes[:letter] = letter - _preprocess_axis_args!(plotattributes) -end - -# axis args in type recipes should only be applied to the current axis -function _postprocess_axis_args!(plotattributes, letter) - pop!(plotattributes, :letter) - if letter in (:x, :y, :z) - for (k, v) in plotattributes - if is_axis_attr_noletter(k) - pop!(plotattributes, k) - lk = Symbol(letter, k) - haskey(plotattributes, lk) || (plotattributes[lk] = v) - end - end - end -end - - -# -------------------------------------------------------------------- -# Fallback user recipes calling type recipes -# -------------------------------------------------------------------- - -# handle "type recipes" by converting inputs, and then either re-calling or slicing -@recipe function f(x, y, z) - wrap_surfaces!(plotattributes, x, y, z) - did_replace = false - newx = _apply_type_recipe(plotattributes, x, :x) - x === newx || (did_replace = true) - newy = _apply_type_recipe(plotattributes, y, :y) - y === newy || (did_replace = true) - newz = _apply_type_recipe(plotattributes, z, :z) - z === newz || (did_replace = true) - if did_replace - newx, newy, newz - else - SliceIt, x, y, z - end -end -@recipe function f(x, y) - wrap_surfaces!(plotattributes, x, y) - did_replace = false - newx = _apply_type_recipe(plotattributes, x, :x) - x === newx || (did_replace = true) - newy = _apply_type_recipe(plotattributes, y, :y) - y === newy || (did_replace = true) - if did_replace - newx, newy - else - SliceIt, x, y, nothing - end -end -@recipe function f(y) - wrap_surfaces!(plotattributes, y) - newy = _apply_type_recipe(plotattributes, y, :y) - if y !== newy - newy - else - SliceIt, nothing, y, nothing - end -end - -# if there's more than 3 inputs, it can't be passed directly to SliceIt -# so we'll apply_type_recipe to all of them -@recipe function f(v1, v2, v3, v4, vrest...) - did_replace = false - newargs = map( - v -> begin - newv = _apply_type_recipe(plotattributes, v, :unknown) - if newv !== v - did_replace = true - end - newv - end, - (v1, v2, v3, v4, vrest...), - ) - if !did_replace - error("Couldn't process recipe args: $(map(typeof, (v1, v2, v3, v4, vrest...)))") - end - newargs -end - - -# helper function to ensure relevant attributes are wrapped by Surface -function wrap_surfaces!(plotattributes, args...) end -wrap_surfaces!(plotattributes, x::AMat, y::AMat, z::AMat) = wrap_surfaces!(plotattributes) -wrap_surfaces!(plotattributes, x::AVec, y::AVec, z::AMat) = wrap_surfaces!(plotattributes) -function wrap_surfaces!(plotattributes, x::AVec, y::AVec, z::Surface) - wrap_surfaces!(plotattributes) -end -function wrap_surfaces!(plotattributes) - if haskey(plotattributes, :fill_z) - v = plotattributes[:fill_z] - if !isa(v, Surface) - plotattributes[:fill_z] = Surface(v) - end - end -end - - # -------------------------------------------------------------------- # 1 argument # -------------------------------------------------------------------- -@recipe f(n::Integer) = is3d(get(plotattributes, :seriestype, :path)) ? (SliceIt, n, n, n) : - (SliceIt, n, n, nothing) - -all3D(plotattributes) = trueOrAllTrue( - st -> st in ( - :contour, - :contourf, - :heatmap, - :surface, - :wireframe, - :contour3d, - :image, - :plots_heatmap, - ), - get(plotattributes, :seriestype, :none), -) - -# return a surface if this is a 3d plot, otherwise let it be sliced up -@recipe function f(mat::AMat) - if all3D(plotattributes) - n, m = axes(mat) - m, n, Surface(mat) - else - nothing, mat, nothing - end -end - -# if a matrix is wrapped by Formatted, do similar logic, but wrap data with Surface -@recipe function f(fmt::Formatted{<:AMat}) - if all3D(plotattributes) - mat = fmt.data - n, m = axes(mat) - m, n, Formatted(Surface(mat), fmt.formatter) - else - nothing, fmt, nothing - end -end - -# assume this is a Volume, so construct one -@recipe function f(vol::AbstractArray{<:MaybeNumber, 3}, args...) - seriestype := :volume - SliceIt, nothing, Volume(vol, args...), nothing -end - - # images - grays function clamp_greys!(mat::AMat{<:Gray}) for i in eachindex(mat) @@ -459,61 +63,11 @@ end end end -# Dicts: each entry is a data point (x,y)=(key,value) - -@recipe f(d::AbstractDict) = collect(keys(d)), collect(values(d)) - -# function without range... use the current range of the x-axis - -@recipe function f(f::FuncOrFuncs{F}) where {F <: Function} - plt = plotattributes[:plot_object] - xmin, xmax = if haskey(plotattributes, :xlims) - plotattributes[:xlims] - else - try - axis_limits(plt[1], :x) - catch - xinv = invscalefunc(get(plotattributes, :xscale, :identity)) - xm = PlotUtils.tryrange(f, xinv.([-5, -1, 0, 0.01])) - xm, PlotUtils.tryrange(f, filter(x -> x > xm, xinv.([5, 1, 0.99, 0, -0.01]))) - end - end - - f, xmin, xmax -end - - -# -------------------------------------------------------------------- -# 2 arguments -# -------------------------------------------------------------------- - -# if functions come first, just swap the order (not to be confused with parametric -# functions... as there would be more than one function passed in) - -@recipe function f(f::FuncOrFuncs{F}, x) where {F <: Function} - F2 = typeof(x) - @assert !(F2 <: Function || (F2 <: AbstractArray && F2.parameters[1] <: Function)) # otherwise we'd hit infinite recursion here - x, f -end - # -------------------------------------------------------------------- # 3 arguments # -------------------------------------------------------------------- -# surface-like... function -@recipe function f(x::AVec, y::AVec, zf::Function) - x, y, Surface(zf, x, y) # TODO: replace with SurfaceFunction when supported -end - -# surface-like... matrix grid -@recipe function f(x::AVec, y::AVec, z::AMat) - if !like_surface(get(plotattributes, :seriestype, :none)) - plotattributes[:seriestype] = :contour - end - x, y, Surface(z) -end - # images - grays @recipe function f(x::AVec, y::AVec, mat::AMat{T}) where {T <: Gray} if is_seriestype_supported(:image) @@ -544,152 +98,13 @@ end end end - -# -------------------------------------------------------------------- -# Parametric functions -# -------------------------------------------------------------------- - -# special handling... xmin/xmax with parametric function(s) -@recipe function f(f::Function, xmin::Number, xmax::Number) - xscale, yscale = [get(plotattributes, sym, :identity) for sym in (:xscale, :yscale)] - _scaled_adapted_grid(f, xscale, yscale, xmin, xmax) -end -@recipe function f(fs::AbstractArray{F}, xmin::Number, xmax::Number) where {F <: Function} - xscale, yscale = [get(plotattributes, sym, :identity) for sym in (:xscale, :yscale)] - unzip(_scaled_adapted_grid.(fs, xscale, yscale, xmin, xmax)) -end -@recipe f( - fx::FuncOrFuncs{F}, - fy::FuncOrFuncs{G}, - u::AVec, -) where {F <: Function, G <: Function} = mapFuncOrFuncs(fx, u), mapFuncOrFuncs(fy, u) -@recipe f( - fx::FuncOrFuncs{F}, - fy::FuncOrFuncs{G}, - umin::Number, - umax::Number, - n = 200, -) where {F <: Function, G <: Function} = fx, fy, range(umin, stop = umax, length = n) - -function _scaled_adapted_grid(f, xscale, yscale, xmin, xmax) - (xf, xinv), (yf, yinv) = ((scalefunc(s), invscalefunc(s)) for s in (xscale, yscale)) - xs, ys = adapted_grid(yf ∘ f ∘ xinv, xf.((xmin, xmax))) - xinv.(xs), yinv.(ys) -end - -# special handling... 3D parametric function(s) -@recipe function f( - fx::FuncOrFuncs{F}, - fy::FuncOrFuncs{G}, - fz::FuncOrFuncs{H}, - u::AVec, -) where {F <: Function, G <: Function, H <: Function} - mapFuncOrFuncs(fx, u), mapFuncOrFuncs(fy, u), mapFuncOrFuncs(fz, u) -end -@recipe function f( - fx::FuncOrFuncs{F}, - fy::FuncOrFuncs{G}, - fz::FuncOrFuncs{H}, - umin::Number, - umax::Number, - numPoints = 200, -) where {F <: Function, G <: Function, H <: Function} - fx, fy, fz, range(umin, stop = umax, length = numPoints) -end - - # -------------------------------------------------------------------- # Lists of tuples and GeometryTypes.Points # -------------------------------------------------------------------- - -@recipe f(v::AVec{<:Tuple}) = unzip(v) @recipe f(v::AVec{<:GeometryTypes.Point}) = unzip(v) -@recipe f(tup::Tuple) = [tup] @recipe f(p::GeometryTypes.Point) = [p] # Special case for 4-tuples in :ohlc series @recipe f(xyuv::AVec{<:Tuple{R1, R2, R3, R4}}) where {R1, R2, R3, R4} = get(plotattributes, :seriestype, :path) == :ohlc ? OHLC[OHLC(t...) for t in xyuv] : unzip(xyuv) - - -# -------------------------------------------------------------------- -# handle grouping -# -------------------------------------------------------------------- - -splittable_kw(key, val, lengthGroup) = false -splittable_kw(key, val::AbstractArray, lengthGroup) = - !(key in (:group, :color_palette)) && length(axes(val, 1)) == lengthGroup -splittable_kw(key, val::Tuple, lengthGroup) = all(splittable_kw.(key, val, lengthGroup)) -splittable_kw(key, val::SeriesAnnotations, lengthGroup) = - splittable_kw(key, val.strs, lengthGroup) - -split_kw(key, val::AbstractArray, indices) = val[indices, fill(Colon(), ndims(val) - 1)...] -split_kw(key, val::Tuple, indices) = Tuple(split_kw(key, v, indices) for v in val) -function split_kw(key, val::SeriesAnnotations, indices) - split_strs = split_kw(key, val.strs, indices) - return SeriesAnnotations(split_strs, val.font, val.baseshape, val.scalefactor) -end - -function groupedvec2mat(x_ind, x, y::AbstractArray, groupby, def_val = y[1]) - y_mat = Array{promote_type(eltype(y), typeof(def_val))}( - undef, - length(keys(x_ind)), - length(groupby.groupLabels), - ) - fill!(y_mat, def_val) - for i in eachindex(groupby.groupLabels) - xi = x[groupby.groupIds[i]] - yi = y[groupby.groupIds[i]] - y_mat[getindex.(Ref(x_ind), xi), i] = yi - end - return y_mat -end - -groupedvec2mat(x_ind, x, y::Tuple, groupby) = - Tuple(groupedvec2mat(x_ind, x, v, groupby) for v in y) - -group_as_matrix(t) = false - -# split the group into 1 series per group, and set the label and idxfilter for each -@recipe function f(groupby::GroupBy, args...) - lengthGroup = maximum(union(groupby.groupIds...)) - if !(group_as_matrix(args[1])) - for (i, glab) in enumerate(groupby.groupLabels) - @series begin - label --> string(glab) - idxfilter --> groupby.groupIds[i] - for (key, val) in plotattributes - if splittable_kw(key, val, lengthGroup) - :($key) := split_kw(key, val, groupby.groupIds[i]) - end - end - args - end - end - else - g = args[1] - if length(g.args) == 1 - x = zeros(Int, lengthGroup) - for indexes in groupby.groupIds - x[indexes] = eachindex(indexes) - end - last_args = g.args - else - x = g.args[1] - last_args = g.args[2:end] - end - x_u = unique(sort(x)) - x_ind = Dict(zip(x_u, eachindex(x_u))) - for (key, val) in plotattributes - if splittable_kw(key, val, lengthGroup) - :($key) := groupedvec2mat(x_ind, x, val, groupby) - end - end - label --> reshape(groupby.groupLabels, 1, :) - typeof(g)(( - x_u, - (groupedvec2mat(x_ind, x, arg, groupby, NaN) for arg in last_args)..., - )) - end -end diff --git a/src/subplots.jl b/src/subplots.jl index 131ccfe2..ef6efda5 100644 --- a/src/subplots.jl +++ b/src/subplots.jl @@ -7,7 +7,7 @@ function Subplot(::T; parent = RootLayout()) where T<:AbstractBackend (20mm, 5mm, 2mm, 10mm), defaultbox, defaultbox, - Attr(KW(), _subplot_defaults), + DefaultsDict(KW(), _subplot_defaults), nothing, nothing ) diff --git a/src/types.jl b/src/types.jl index a866e59f..5a7427c5 100644 --- a/src/types.jl +++ b/src/types.jl @@ -18,65 +18,10 @@ end wrap(obj::T) where {T} = InputWrapper{T}(obj) Base.isempty(wrapper::InputWrapper) = false - -# ----------------------------------------------------------- - -struct Attr <: AbstractDict{Symbol,Any} - explicit::KW - defaults::KW -end - -function Base.getindex(attr::Attr, k) - return haskey(attr.explicit, k) ? attr.explicit[k] : attr.defaults[k] -end -Base.haskey(attr::Attr, k) = haskey(attr.explicit,k) || haskey(attr.defaults,k) -Base.get(attr::Attr, k, default) = haskey(attr, k) ? attr[k] : default -function Base.get!(attr::Attr, k, default) - v = if haskey(attr, k) - attr[k] - else - attr.defaults[k] = default - end - return v -end -function Base.delete!(attr::Attr, k) - haskey(attr.explicit, k) && delete!(attr.explicit, k) - haskey(attr.defaults, k) && delete!(attr.defaults, k) -end -Base.length(attr::Attr) = length(union(keys(attr.explicit), keys(attr.defaults))) -function Base.iterate(attr::Attr) - exp_keys = keys(attr.explicit) - def_keys = setdiff(keys(attr.defaults), exp_keys) - key_list = collect(Iterators.flatten((exp_keys, def_keys))) - iterate(attr, (key_list, 1)) -end -function Base.iterate(attr::Attr, (key_list, i)) - i > length(key_list) && return nothing - k = key_list[i] - (k=>attr[k], (key_list, i+1)) -end - -Base.copy(attr::Attr) = Attr(copy(attr.explicit), attr.defaults) - -RecipesBase.is_explicit(attr::Attr, k) = haskey(attr.explicit,k) -isdefault(attr::Attr, k) = !is_explicit(attr,k) && haskey(attr.defaults,k) - -Base.setindex!(attr::Attr, v, k) = attr.explicit[k] = v - -# Reset to default value and return dict -reset_kw!(attr::Attr, k) = is_explicit(attr, k) ? delete!(attr.explicit, k) : attr -# Reset to default value and return old value -pop_kw!(attr::Attr, k) = is_explicit(attr, k) ? pop!(attr.explicit, k) : attr.defaults[k] -pop_kw!(attr::Attr, k, default) = is_explicit(attr, k) ? pop!(attr.explicit, k) : get(attr.defaults, k, default) -# Fallbacks for dicts without defaults -reset_kw!(d::AKW, k) = delete!(d, k) -pop_kw!(d::AKW, k) = pop!(d, k) -pop_kw!(d::AKW, k, default) = pop!(d, k, default) - # ----------------------------------------------------------- mutable struct Series - plotattributes::Attr + plotattributes::DefaultsDict end attr(series::Series, k::Symbol) = series.plotattributes[k] @@ -91,7 +36,7 @@ mutable struct Subplot{T<:AbstractBackend} <: AbstractLayout minpad::Tuple # leftpad, toppad, rightpad, bottompad bbox::BoundingBox # the canvas area which is available to this subplot plotarea::BoundingBox # the part where the data goes - attr::Attr # args specific to this subplot + attr::DefaultsDict # args specific to this subplot o # can store backend-specific data... like a pyplot ax plt # the enclosing Plot object (can't give it a type because of no forward declarations) end @@ -103,7 +48,7 @@ Base.show(io::IO, sp::Subplot) = print(io, "Subplot{$(sp[:subplot_index])}") # simple wrapper around a KW so we can hold all attributes pertaining to the axis in one place mutable struct Axis sps::Vector{Subplot} - plotattributes::Attr + plotattributes::DefaultsDict end mutable struct Extrema @@ -122,7 +67,7 @@ const SubplotMap = Dict{Any, Subplot} mutable struct Plot{T<:AbstractBackend} <: AbstractPlot{T} backend::T # the backend type n::Int # number of series - attr::Attr # arguments for the whole plot + attr::DefaultsDict # arguments for the whole plot series_list::Vector{Series} # arguments for each series o # the backend's plot object subplots::Vector{Subplot} @@ -133,7 +78,7 @@ mutable struct Plot{T<:AbstractBackend} <: AbstractPlot{T} end function Plot() - Plot(backend(), 0, Attr(KW(), _plot_defaults), Series[], nothing, + Plot(backend(), 0, DefaultsDict(KW(), _plot_defaults), Series[], nothing, Subplot[], SubplotMap(), EmptyLayout(), Subplot[], false) end diff --git a/src/utils.jl b/src/utils.jl index c28a67e9..f20a215f 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -143,9 +143,6 @@ makevec(v::T) where {T} = T[v] maketuple(x::Real) = (x,x) maketuple(x::Tuple{T,S}) where {T,S} = x -mapFuncOrFuncs(f::Function, u::AVec) = map(f, u) -mapFuncOrFuncs(fs::AVec{F}, u::AVec) where {F<:Function} = [map(f, u) for f in fs] - for i in 2:4 @eval begin unzip(v::Union{AVec{<:Tuple{Vararg{T,$i} where T}}, @@ -229,7 +226,7 @@ end "create an (n+1) list of the outsides of heatmap rectangles" function heatmap_edges(v::AVec, scale::Symbol = :identity, isedges::Bool = false) - f, invf = scalefunc(scale), invscalefunc(scale) + f, invf = scale_func(scale), inverse_scale_func(scale) map(invf, _heatmap_edges(map(f,v), isedges)) end