From 08eb4c98c324008eb924b8053416b95d2db6ccb4 Mon Sep 17 00:00:00 2001 From: Daniel Schwabeneder Date: Sun, 5 Apr 2020 11:45:18 +0200 Subject: [PATCH] rewrite from https://github.com/JuliaPlots/Plots.jl/pull/2530 --- RecipesPipeline/Project.toml | 3 + RecipesPipeline/src/RecipePipeline.jl | 97 +++++++- RecipesPipeline/src/api.jl | 142 +++++++++++ RecipesPipeline/src/default_recipes.jl | 184 -------------- RecipesPipeline/src/group.jl | 122 +++++++++ RecipesPipeline/src/pipeline.jl | 56 ----- RecipesPipeline/src/plot_recipe.jl | 46 ++++ RecipesPipeline/src/process_recipes.jl | 136 ---------- RecipesPipeline/src/series.jl | 170 +++++++++++++ RecipesPipeline/src/series_recipe.jl | 62 +++++ RecipesPipeline/src/type_recipe.jl | 94 +++++++ RecipesPipeline/src/user_recipe.jl | 330 +++++++++++++++++++++++++ RecipesPipeline/src/utils.jl | 220 +++++++++++++++++ 13 files changed, 1282 insertions(+), 380 deletions(-) create mode 100644 RecipesPipeline/src/api.jl delete mode 100644 RecipesPipeline/src/default_recipes.jl create mode 100644 RecipesPipeline/src/group.jl delete mode 100644 RecipesPipeline/src/pipeline.jl create mode 100644 RecipesPipeline/src/plot_recipe.jl delete mode 100644 RecipesPipeline/src/process_recipes.jl create mode 100644 RecipesPipeline/src/series.jl create mode 100644 RecipesPipeline/src/series_recipe.jl create mode 100644 RecipesPipeline/src/type_recipe.jl create mode 100644 RecipesPipeline/src/user_recipe.jl create mode 100644 RecipesPipeline/src/utils.jl diff --git a/RecipesPipeline/Project.toml b/RecipesPipeline/Project.toml index 918c2877a..1f5f6f63d 100644 --- a/RecipesPipeline/Project.toml +++ b/RecipesPipeline/Project.toml @@ -4,10 +4,13 @@ authors = ["Michael Krabbe Borregaard "] version = "0.1.0" [deps] +PlotUtils = "995b91a9-d308-5afd-9ec6-746e21dbc043" RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" [compat] julia = "1" +PlotUtils = "0.6.5" +RecipesBase = "0.8" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/RecipesPipeline/src/RecipePipeline.jl b/RecipesPipeline/src/RecipePipeline.jl index 376507e01..03cc24e60 100644 --- a/RecipesPipeline/src/RecipePipeline.jl +++ b/RecipesPipeline/src/RecipePipeline.jl @@ -1,8 +1,97 @@ module RecipePipeline import RecipesBase -include("pipeline.jl") -include("process_recipes.jl") -include("default_recipes.jl") +import RecipesBase: @recipe, @series, RecipeData, is_explicit +import PlotUtils # tryrange and adapted_grid -end # module +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/RecipesPipeline/src/api.jl b/RecipesPipeline/src/api.jl new file mode 100644 index 000000000..feb9350ae --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/default_recipes.jl b/RecipesPipeline/src/default_recipes.jl deleted file mode 100644 index ea8808bc0..000000000 --- a/RecipesPipeline/src/default_recipes.jl +++ /dev/null @@ -1,184 +0,0 @@ -# aliases -const AVec = AbstractVector -const AMat = AbstractMatrix - -# ensure we dispatch to the slicer -struct SliceIt end - -"Represents data values with formatting that should apply to the tick labels." -struct Formatted{T} - data::T - formatter::Function -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) - @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 - -# ----------------------------------------------------------------------- -# the catch-all recipes -RecipesBase.@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 = convertToAnyVector(x, plotattributes) - ys = convertToAnyVector(y, plotattributes) - zs = convertToAnyVector(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 - -# this is the default "type recipe"... just pass the object through -RecipesBase.@recipe f(::Type{T}, v::T) where {T<:Any} = v - -# this should catch unhandled "series recipes" and error with a nice message -RecipesBase.@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.") - -# 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} - -prepareSeriesData(x) = error("Cannot convert $(typeof(x)) to series data for plotting") -prepareSeriesData(::Nothing) = nothing -prepareSeriesData(t::Tuple{T, T}) where {T<:Number} = t -prepareSeriesData(f::Function) = f -prepareSeriesData(a::AbstractArray{<:MaybeNumber}) = replace!( - x -> ismissing(x) || isinf(x) ? NaN : x, - map(float,a)) -prepareSeriesData(a::AbstractArray{<:MaybeString}) = replace(x -> ismissing(x) ? "" : x, a) -prepareSeriesData(s::Surface{<:AMat{<:MaybeNumber}}) = Surface(prepareSeriesData(s.surf)) -prepareSeriesData(s::Surface) = s # non-numeric Surface, such as an image -prepareSeriesData(v::Volume) = Volume(prepareSeriesData(v.v), v.x_extents, v.y_extents, v.z_extents) - -# default: assume x represents a single series -convertToAnyVector(x, plotattributes) = Any[prepareSeriesData(x)] - -# fixed number of blank series -convertToAnyVector(n::Integer, plotattributes) = Any[zeros(0) for i in 1:n] - -# vector of data points is a single series -convertToAnyVector(v::AVec{<:DataPoint}, plotattributes) = Any[prepareSeriesData(v)] - -# list of things (maybe other vectors, functions, or something else) -function convertToAnyVector(v::AVec, plotattributes) - if all(x -> x isa MaybeNumber, v) - convertToAnyVector(Vector{MaybeNumber}(v), plotattributes) - elseif all(x -> x isa MaybeString, v) - convertToAnyVector(Vector{MaybeString}(v), plotattributes) - else - vcat((convertToAnyVector(vi, plotattributes) for vi in v)...) - end -end - -# Matrix is split into columns -function convertToAnyVector(v::AMat{<:DataPoint}, plotattributes) - if all3D(plotattributes) - Any[prepareSeriesData(Surface(v))] - else - Any[prepareSeriesData(v[:, i]) for i in axes(v, 2)] - end -end - -# -------------------------------------------------------------------- -# Fillranges & ribbons - - -process_fillrange(range::Number, plotattributes) = [range] -process_fillrange(range, plotattributes) = convertToAnyVector(range, plotattributes) - -process_ribbon(ribbon::Number, plotattributes) = [ribbon] -process_ribbon(ribbon, plotattributes) = convertToAnyVector(ribbon, plotattributes) -# ribbon as a tuple: (lower_ribbons, upper_ribbons) -process_ribbon(ribbon::Tuple{Any,Any}, plotattributes) = collect(zip(convertToAnyVector(ribbon[1], plotattributes), - convertToAnyVector(ribbon[2], plotattributes))) - - -all3D(plotattributes) = trueOrAllTrue(st -> st in (:contour, :contourf, :heatmap, :surface, :wireframe, :contour3d, :image, :plots_heatmap), get(plotattributes, :seriestype, :none)) - -trueOrAllTrue(f::Function, x::AbstractArray) = all(f, x) -trueOrAllTrue(f::Function, x) = f(x) diff --git a/RecipesPipeline/src/group.jl b/RecipesPipeline/src/group.jl new file mode 100644 index 000000000..ca9d46c5c --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/pipeline.jl b/RecipesPipeline/src/pipeline.jl deleted file mode 100644 index 07ca62e02..000000000 --- a/RecipesPipeline/src/pipeline.jl +++ /dev/null @@ -1,56 +0,0 @@ -## Stubs -function _recipe_init!(plt, plotattributes, args) end -function _recipe_after_user!(plt, plotattributes, args) end -function _recipe_after_plot!(plt, plotattributes, kw_list) end -function _recipe_before_series!(plt, kw, kw_list) kw end # this must always return the kwargs -function _recipe_after_series!(plt, kw, series_ind) end -function _recipe_finish!(plt, plotattributes, args) plt end - -## -# Here comes the specification of when which recipe is processed. -# It contains functions before and after every stage for interaction with the plotting package. - -function recipe_pipeline!(plt, # frontend specific representation of a plot - plotattributes, # current state of recipe keywords - args; # set of arguments passed by the user - type_aliases = Dict{Symbol, Symbol}()) - - _recipe_init!(plt, plotattributes, args) - - # -------------------------------- - # "USER RECIPES" - # -------------------------------- - kw_list = _process_userrecipes(plt, plotattributes, args) - _recipe_after_user!(plt, plotattributes, args) - - # -------------------------------- - # "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 = Dict{Symbol,Any}[] - while !isempty(still_to_process) - next_kw = popfirst!(still_to_process) - _process_plotrecipe(plt, next_kw, kw_list, still_to_process; type_aliases=type_aliases) - end - - _recipe_after_plot!(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" - # -------------------------------- - for (series_ind, kw) in enumerate(kw_list) - series_attr = _recipe_before_series!(plt, kw, kw_list) - _process_seriesrecipe(plt, series_attr, type_aliases=type_aliases) - _recipe_after_series!(plt, kw, series_ind) - end - - _recipe_finish!(plt, plotattributes, args) -end diff --git a/RecipesPipeline/src/plot_recipe.jl b/RecipesPipeline/src/plot_recipe.jl new file mode 100644 index 000000000..6b7e05c3b --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/process_recipes.jl b/RecipesPipeline/src/process_recipes.jl deleted file mode 100644 index 630bda1dc..000000000 --- a/RecipesPipeline/src/process_recipes.jl +++ /dev/null @@ -1,136 +0,0 @@ -# # The Recipe Consumer Interface -# To consume RecipesBase recipes, we allow plotting packages to overload the following -# hooks into the main Recipe pipeline. The docstrings should eventually describe all -# necessary functionality. -# All these methods have the first parameter as the plot object which is being acted on. -# This allows for a dispatch overload by any consumer of these recipes. - -""" - _preprocess_args(p, args, s) - -Take in a Vector of RecipeData (`s`) and fill in default attributes. -""" -_preprocess_args(p, args, s) = append!(s, RecipesBase.RecipeData[RecipesBase.RecipeData(convert(Dict{Symbol, Any}, copy(p)), args)]) # needs to modify still_to_process -# TODO define RecipesBase constructor better -""" -""" -_process_userrecipe(plt, kw_list, next_series) = nothing - -""" -""" -preprocessArgs!(p) = p - -""" -""" -is_st_supported(plt, st) = true - -""" -""" -finalize_subplot!(plt, st, att) = nothing - -function _process_userrecipes(plt, plotattributes::T, args) where T <: AbstractDict{Symbol, <: Any} - still_to_process = RecipesBase.RecipeData[] - # This should modify `still_to_process`! - _preprocess_args(plotattributes, args, still_to_process) - - # 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 = Dict{Symbol,Any}[] - 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) <: RecipesBase.RecipeData) - @error("Inputs couldn't be processed. Expected RecipeData but got: $next_series") - end - if isempty(next_series.args) - # WARNING: you need to define this on your - # custom plot type! - _process_userrecipe(plt, kw_list, next_series) - else - rd_list = RecipesBase.apply_recipe(next_series.plotattributes, next_series.args...) - prepend!(still_to_process,rd_list) - end - end - - kw_list -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, kw::AbstractDict{Symbol,Any}, kw_list::Vector{Dict{Symbol,Any}}, still_to_process::Vector{Dict{Symbol,Any}}; type_aliases::AbstractDict{Symbol, Symbol}=Dict{Symbol,Symbol}()) - 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(type_aliases, st, st) - datalist = RecipesBase.apply_recipe(kw, Val{st}, plt) - for data in datalist - preprocessArgs!(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 - -# ------------------------------------------------------------------------------- - -# this method recursively applies series recipes when the seriestype is not supported -# natively by the backend -function _process_seriesrecipe(plt, plotattributes::AbstractDict{Symbol,Any}; type_aliases::AbstractDict{Symbol,Symbol} = Dict{Symbol,Symbol}()) - - # replace seriestype aliases - st = Symbol(plotattributes[:seriestype]) - st = plotattributes[:seriestype] = get(type_aliases, 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_st_supported(plt, st) - finalize_subplot!(plt, st, plotattributes) - - else - # get a sub list of series for this seriestype - datalist = RecipesBase.apply_recipe(plotattributes, Val{st}, plotattributes[:x], plotattributes[:y], plotattributes[:z]) - - # assuming there was no error, recursively apply the series recipes - for data in datalist - if isa(data, RecipesBase.RecipeData) - preprocessArgs!(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 - return nothing -end diff --git a/RecipesPipeline/src/series.jl b/RecipesPipeline/src/series.jl new file mode 100644 index 000000000..e967fa4c1 --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/series_recipe.jl b/RecipesPipeline/src/series_recipe.jl new file mode 100644 index 000000000..37bb6a4ce --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/type_recipe.jl b/RecipesPipeline/src/type_recipe.jl new file mode 100644 index 000000000..527fbae69 --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/user_recipe.jl b/RecipesPipeline/src/user_recipe.jl new file mode 100644 index 000000000..0023474f2 --- /dev/null +++ b/RecipesPipeline/src/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/RecipesPipeline/src/utils.jl b/RecipesPipeline/src/utils.jl new file mode 100644 index 000000000..54862d47c --- /dev/null +++ b/RecipesPipeline/src/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]