diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ec6c7abc..103b34528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,6 @@ jobs: GKS_ENCODING: "utf8" GKSwstype: "nul" PYTHON: "Conda" # for `PyPlot` - name: Julia ${{ matrix.version }} - ${{ matrix.os }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.version == 'nightly' }} @@ -31,17 +30,16 @@ jobs: - '1.6' # LTS (minimal declared julia compat in `Project.toml`) - '1.8' # latest stable os: [ubuntu-latest, windows-latest, macos-latest] - arch: - - x64 + arch: [x64] include: - os: ubuntu-latest prefix: xvfb-run # julia-actions/julia-runtest/blob/master/README.md - os: ubuntu-latest prefix: xvfb-run - version: '1.7' + version: '1.7' # only test intermediate relase on `ubuntu` # - os: ubuntu-latest # prefix: xvfb-run - # version: 'nightly' + # version: 'nightly' steps: - uses: actions/checkout@v3 @@ -49,15 +47,17 @@ jobs: with: version: ${{ matrix.version }} - uses: julia-actions/cache@v1 + - uses: julia-actions/julia-buildpkg@latest - - name: Ubuntu LaTeX dependencies - if: startsWith(matrix.os, 'ubuntu') + - name: test upstream RecipesBase + shell: julia --project=@. --color=yes {0} run: | - sudo apt-get -y update - sudo apt-get -y install gnuplot poppler-utils texlive-{latex-base,latex-extra,luatex} - sudo fc-cache -vr + using Pkg; Pkg.develop(path="RecipesBase"); Pkg.test("RecipesBase") - - uses: julia-actions/julia-buildpkg@latest + - name: test upstream RecipesPipeline + shell: julia --project=@. --color=yes {0} + run: | + using Pkg; Pkg.develop(path="RecipesPipeline"); Pkg.test("RecipesPipeline") - name: PyPlot dependencies shell: julia --project=@. --color=yes {0} @@ -71,15 +71,23 @@ jobs: Conda.add("matplotlib") Conda.list() + - name: Ubuntu LaTeX dependencies + if: startsWith(matrix.os, 'ubuntu') + run: | + sudo apt-get -y update + sudo apt-get -y install gnuplot poppler-utils texlive-{latex-base,latex-extra,luatex} + sudo fc-cache -vr + - uses: julia-actions/julia-runtest@latest with: prefix: ${{ matrix.prefix }} # for `xvfb-run` - name: Run downstream tests if: startsWith(matrix.os, 'ubuntu') - run: | - xvfb-run julia -e 'using Pkg; Pkg.activate(tempdir()); Pkg.develop(path=abspath(".")); Pkg.add("StatsPlots"); Pkg.test("StatsPlots")' - xvfb-run julia -e 'using Pkg; Pkg.activate(tempdir()); Pkg.develop(path=abspath(".")); Pkg.add("GraphRecipes"); Pkg.test("GraphRecipes")' + shell: xvfb-run julia --project=@. --color=yes {0} + run: | + using Pkg; Pkg.activate(tempdir()); Pkg.develop(path=abspath(".")); Pkg.add("StatsPlots"); Pkg.test("StatsPlots") + using Pkg; Pkg.activate(tempdir()); Pkg.develop(path=abspath(".")); Pkg.add("GraphRecipes"); Pkg.test("GraphRecipes") - uses: julia-actions/julia-processcoverage@latest if: startsWith(matrix.os, 'ubuntu') diff --git a/RecipesBase/LICENSE.md b/RecipesBase/LICENSE.md new file mode 100644 index 000000000..209d1ea4a --- /dev/null +++ b/RecipesBase/LICENSE.md @@ -0,0 +1,22 @@ +The RecipesBase.jl package is licensed under the MIT "Expat" License: + +> Copyright (c) 2016: Thomas Breloff. +> +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of this software and associated documentation files (the +> "Software"), to deal in the Software without restriction, including +> without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to +> permit persons to whom the Software is furnished to do so, subject to +> the following conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +> IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +> CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +> TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +> SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/RecipesBase/Project.toml b/RecipesBase/Project.toml new file mode 100644 index 000000000..5e266ddb1 --- /dev/null +++ b/RecipesBase/Project.toml @@ -0,0 +1,18 @@ +name = "RecipesBase" +uuid = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" +author = ["Tom Breloff (@tbreloff)"] +version = "1.3.0" + +[deps] +SnoopPrecompile = "66db9d55-30c0-4569-8b51-7e840670fc0c" + +[compat] +SnoopPrecompile = "1" +julia = "1.6" + +[extras] +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Random", "Test"] diff --git a/RecipesBase/README.md b/RecipesBase/README.md new file mode 100644 index 000000000..f559309b5 --- /dev/null +++ b/RecipesBase/README.md @@ -0,0 +1,150 @@ +# RecipesBase + +[![](https://img.shields.io/badge/docs-stable-blue.svg)](https://JuliaPlots.github.io/RecipesBase.jl/stable) +[![](https://img.shields.io/badge/docs-dev-blue.svg)](https://JuliaPlots.github.io/RecipesBase.jl/dev) +[![Build Status](https://travis-ci.org/JuliaPlots/RecipesBase.jl.svg?branch=master)](https://travis-ci.org/JuliaPlots/RecipesBase.jl) +[![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://julialang.zulipchat.com/#narrow/stream/236493-plots) +[![deps](https://juliahub.com/docs/RecipesBase/deps.svg)](https://juliahub.com/ui/Packages/RecipesBase/8e2Mm?t=2) + +### Author: Thomas Breloff (@tbreloff) + +This package implements handy macros `@recipe` and `@series` which will define a custom transformation +and attach attributes for user types. Its design is an attempt to simplify and generalize +the summary and display of types and data from external packages. With no extra dependencies +and minimal code, package authors can describe visualization routines that can be used +as components in more complex visualizations. + +This functionality is primarily geared to turning user types and settings into the +data and attributes that describe a [Plots](https://github.com/tbreloff/Plots.jl) visualization, +though it could be used for other purposes as well. +Plots has extensive machinery to uniquely take advantage of the simplified recipe description you define. See the [Plots documentation on recipes](http://docs.juliaplots.org/latest/recipes/) for more information. + +The `@recipe` macro will process a function definition, use `-->` commands to define attributes, and +pass the return value through for further processing (likely by Plots.jl). + +## Why should I care about this package? + +Many packages have custom types and custom data. There is usually specialized structure, and useful +methods of visualizing that structure and data. This package solves the difficult problem of how to +build generic visualizations of user-defined data types, without adding bulky dependencies on complex +graphics packages. + +This package is as lightweight as possible. It exports two macros, and defines only a few internal methods. +It has **zero dependencies**. + +However, although it is lightweight, it enables a lot. The entirety of the Plots framework becomes available +to any package implementing a recipe. This means that complex plots and subplots can be built with uber-flexibility +using custom combinations of data types. Some examples of applications: + +- Distributions: overlayed density plots for non-normal fitted distributions. +- DataFrames: "Grammar of Graphics"-style inputs using symbols. +- Deep Learning: frameworks for visualization of neural network states and tracking of internal calculations. +- Graphs: flexible, interactive graphs with easily customizable colors, etc. +- Symbolic frameworks: sample from complex symbolic distributions. + +Really there's very little that *couldn't* be mapped to a useful visualization. +I challenge you to create the pictures that are worth a thousand words. + +For more information about Plots, see [the docs](http://juliaplots.github.io/), and be sure to reference +the [supported keywords](https://docs.juliaplots.org/stable/generated/supported/#Keyword-Arguments). +For additional examples of recipes in the wild, see [PlotRecipes](https://github.com/JuliaPlots/PlotRecipes.jl). +Ask questions on [gitter](https://gitter.im/tbreloff/Plots.jl) or in the issues. + +## Hello world + +This will build a spiky surface: + +```julia +using Plots; gr() +struct T end +@recipe f(::T) = rand(100,100) +surface(T()) +``` + +![](https://cloud.githubusercontent.com/assets/933338/15089193/7a453ec6-13cc-11e6-9ae8-959e98b615dc.png) + +## A real example + +```julia +using RecipesBase + +# Our user-defined data type +struct T end + +# This is all we define. It uses a familiar signature, but strips it apart +# in order to add a custom definition to the internal method `RecipesBase.apply_recipe` +@recipe function plot(::T, n = 1; customcolor = :green) + markershape --> :auto # if markershape is unset, make it :auto + markercolor := customcolor # force markercolor to be customcolor + xrotation --> 45 # if xrotation is unset, make it 45 + zrotation --> 90 # if zrotation is unset, make it 90 + rand(10,n) # return the arguments (input data) for the next recipe +end + +# ---------------------------- + +# Plots will be the ultimate consumer of our recipe in this example +using Plots +gr() + +# This call will implicitly call `RecipesBase.apply_recipe` as part of the Plots +# processing pipeline (see the Pipeline section of the Plots documentation). +# It will plot 5 line plots (a 5-column matrix is returned from the recipe). +# All will have black circles: +# - user override for markershape: :c == :circle +# - customcolor overridden to :black, and markercolor is forced to be customcolor +# If markershape is an unsupported keyword, the call will error. +# By default, a warning will be shown for an unsupported keyword. +# This will be suppressed for zrotation (:quiet flag). +plot(T(), 5; customcolor = :black, shape=:c) +``` + +![](https://cloud.githubusercontent.com/assets/933338/15083906/02a06810-139e-11e6-98a0-dd81c3fb1ad8.png) + +In this example, we see a lot of the machinery in action. We create a new type `T`, which +we will use for dispatch, and an optional argument `n`, which will be used to determine the +number of series to display. User-defined keyword arguments are passed through, and the +`-->` command can be trailed by flags: + +- `quiet`: Suppress unsupported keyword warnings +- `require`: Error if keyword is unsupported +- `force`: Don't allow user override for this keyword + +### Series + +For complex visualizations, it can be beneficial to create many series inside a single recipe. The `@series` macro will make a copy of the attribute dictionary `d`, and add a new RecipeData object to the returned list. See the [case studies](http://docs.juliaplots.org/latest/recipes/#case-studies) for more details. + +### Generated code + +For the example above, the following code is generated. In it, you can see the managing of the scope of the keyword args, creation of a definition for `RecipesBase.apply_recipe`, setting attributes, and creating the list of `RecipeData` objects: + +```julia +function RecipesBase.apply_recipe(d::Dict{Symbol,Any},::T,n=1) + if RecipesBase._debug_recipes[1] + println("apply_recipe args: ",Any[:(::T),:(n=1)]) + end + begin + customcolor = get!(d,:customcolor,:green) + end + series_list = RecipesBase.RecipeData[] + func_return = begin + get!(d,:markershape,:auto) + d[:markercolor] = customcolor + get!(d,:xrotation,45) + get!(d,:zrotation,90) + rand(10,n) + end + if func_return != nothing + push!(series_list,RecipesBase.RecipeData(d,RecipesBase.wrap_tuple(func_return))) + end + begin + RecipesBase.is_key_supported(:customcolor) || delete!(d,:customcolor) + end + series_list +end +``` + +### A humble request + +If you build a recipe for your package, please let me know! I'd love to compile both a gallery and +a listing of user-defined recipes, as well as the packages that are available for Plots visualizations. diff --git a/RecipesBase/src/RecipesBase.jl b/RecipesBase/src/RecipesBase.jl new file mode 100644 index 000000000..da65e6d8f --- /dev/null +++ b/RecipesBase/src/RecipesBase.jl @@ -0,0 +1,626 @@ +module RecipesBase + +using SnoopPrecompile + +export + @recipe, + @series, + @userplot, + @shorthands, + @layout, + RecipeData, + AbstractBackend, + AbstractPlot, + AbstractLayout + +# Common abstract types for the Plots ecosystem +abstract type AbstractBackend end +abstract type AbstractPlot{T<:AbstractBackend} end +abstract type AbstractLayout end + +const KW = Dict{Symbol,Any} +const AKW = AbstractDict{Symbol,Any} + +# a placeholder to establish the name so that other packages (Plots.jl for example) +# can add their own definition of RecipesBase.plot since RecipesBase is the common +# dependency of the Plots ecosystem +function plot end +function plot! end + +# a placeholder to establish the name so that other packages (Plots.jl for example) +# can add their own definition of RecipesBase.animate since RecipesBase is the common +# dependency of the Plots ecosystem. Plots.jl will handle the basic cases, while +# other packages can now extend for their types +function animate end + +# a placeholder to establish the name so that other packages (Plots.jl for example) +# can add their own definition of RecipesBase.is_key_supported(k::Symbol) +function is_key_supported end + +function grid end + +# a placeholder to establish the name so that other packages (Plots.jl for example) +# can add their own definition of RecipesBase.group_as_matrix(t) +group_as_matrix(t) = false + +# This holds the recipe definitions to be dispatched on +# the function takes in an attribute dict `d` and a list of args. +# This default definition specifies the "no-arg" case. +apply_recipe(plotattributes::AbstractDict{Symbol,Any}) = () + +# Is a key explicitly provided by the user? +# Should be overridden for subtypes representing plot attributes. +is_explicit(d::AbstractDict{Symbol,Any}, k) = haskey(d, k) + +const _debug_recipes = Bool[false] +function debug(v::Bool = true) + _debug_recipes[1] = v +end + +# -------------------------------------------------------------------------- + +# this holds the data and attributes of one series, and is returned from apply_recipe +struct RecipeData + plotattributes::AbstractDict{Symbol,Any} + args::Tuple +end + +# -------------------------------------------------------------------------- + +@inline to_symbol(s::Symbol) = s +@inline to_symbol(qn::QuoteNode) = qn.value + +@inline wrap_tuple(tup::Tuple) = tup +@inline wrap_tuple(v) = (v,) + +# check for flags as part of the `-->` expression +function _is_arrow_tuple(expr::Expr) + expr.head == :tuple && !isempty(expr.args) && + isa(expr.args[1], Expr) && + expr.args[1].head == :(-->) +end + +function _equals_symbol(arg::Symbol, sym::Symbol) + arg == sym +end +function _equals_symbol(arg::Expr, sym::Symbol) #not sure this method is necessary anymore on 0.7 + arg.head == :quote && arg.args[1] == sym +end +function _equals_symbol(arg::QuoteNode, sym::Symbol) + arg.value == sym +end +_equals_symbol(x, sym::Symbol) = false + +# build an apply_recipe function header from the recipe function header +function get_function_def(func_signature::Expr, args::Vector) + front = func_signature.args[1] + if func_signature.head == :where + Expr(:where, get_function_def(front, args), esc.(func_signature.args[2:end])...) + elseif func_signature.head == :call + func = Expr(:call, :(RecipesBase.apply_recipe), esc.([:(plotattributes::AbstractDict{Symbol, Any}); args])...) + if isa(front, Expr) && front.head == :curly + Expr(:where, func, esc.(front.args[2:end])...) + else + func + end + else + error("Expected `func_signature = ...` with func_signature as a call or where Expr... got: $func_signature") + end +end + +function create_kw_body(func_signature::Expr) + # get the arg list, stripping out any keyword parameters into a + # bunch of get!(kw, key, value) lines + func_signature.head == :where && return create_kw_body(func_signature.args[1]) + args = func_signature.args[2:end] + kw_body = Expr(:block) + cleanup_body = Expr(:block) + arg1 = args[1] + if isa(arg1, Expr) && arg1.head == :parameters + for kwpair in arg1.args + k, v = kwpair.args + if isa(k, Expr) && k.head == :(::) + k = k.args[1] + @warn("Type annotations on keyword arguments not currently supported in recipes. Type information has been discarded") + end + push!(kw_body.args, :($k = get!(plotattributes, $(QuoteNode(k)), $v))) + push!(cleanup_body.args, :(RecipesBase.is_key_supported($(QuoteNode(k))) || delete!(plotattributes, $(QuoteNode(k))))) + end + args = args[2:end] + end + args, kw_body, cleanup_body +end + +# process the body of the recipe recursively. +# when we see the series macro, we split that block off: + # let + # d2 = copy(d) + # + # RecipeData(d2, block_return) + # end +# and we push this block onto the series_blocks list. +# then at the end we push the main body onto the series list +function process_recipe_body!(expr::Expr) + for (i,e) in enumerate(expr.args) + if isa(e,Expr) + + # process trailing flags, like: + # a --> b, :quiet, :force + quiet, require, force = false, false, false + if _is_arrow_tuple(e) + for flag in e.args + if _equals_symbol(flag, :quiet) + quiet = true + elseif _equals_symbol(flag, :require) + require = true + elseif _equals_symbol(flag, :force) + force = true + end + end + e = e.args[1] + end + + # the unused operator `:=` will mean force: `x := 5` is equivalent to `x --> 5, force` + # note: this means "x is defined as 5" + if e.head == :(:=) + force = true + e.head = :(-->) + end + + # we are going to recursively swap out `a --> b, flags...` commands + # note: this means "x may become 5" + if e.head == :(-->) + k, v = e.args + if isa(k, Symbol) + k = QuoteNode(k) + end + + set_expr = if force + # forced override user settings + :(plotattributes[$k] = $v) + else + # if the user has set this keyword, use theirs + :(RecipesBase.is_explicit(plotattributes, $k) || (plotattributes[$k] = $v)) + end + + expr.args[i] = if quiet + # quietly ignore keywords which are not supported + :(RecipesBase.is_key_supported($k) ? $set_expr : nothing) + elseif require + # error when not supported by the backend + :(RecipesBase.is_key_supported($k) ? $set_expr : error("In recipe: required keyword ", $k, " is not supported by backend $(backend_name())")) + else + set_expr + end + + elseif e.head == :return + # To allow `return` in recipes just extract the returned arguments. + expr.args[i] = first(e.args) + + elseif e.head != :call + # we want to recursively replace the arrows, but not inside function calls + # as this might include things like Dict(1=>2) + process_recipe_body!(e) + end + end + end +end + +# -------------------------------------------------------------------------- + +""" +This handy macro will process a function definition, replace `-->` commands, and +then add a new version of `RecipesBase.apply_recipe` for dispatching on the arguments. + +This functionality is primarily geared to turning user types and settings into the +data and attributes that describe a Plots.jl visualization. + +Set attributes using the `-->` command, and return a comma separated list of arguments that +should replace the current arguments. + +An example: + +```julia +using RecipesBase + +# Our custom type that we want to display +struct T end + +@recipe function plot(t::T, n::Integer = 1; customcolor = :green) + markershape --> :auto, :require + markercolor --> customcolor, :force + xrotation --> 5 + zrotation --> 6, :quiet + rand(10,n) +end + +# --------------------- + +# Plots will be the ultimate consumer of our recipe in this example +using Plots; gr() + +# This call will implicitly call `RecipesBase.apply_recipe` as part of the Plots +# processing pipeline (see the Pipeline section of the Plots documentation). +# It will plot 5 line plots, all with black circles for markers. +# The markershape argument must be supported, and the zrotation argument's warning +# will be suppressed. The user can override all arguments except markercolor. +plot(T(), 5; customcolor = :black, shape=:c) +``` + +In this example, we see lots of the machinery in action. We create a new type `T` which +we will use for dispatch, and an optional argument `n`, which will be used to determine the +number of series to display. User-defined keyword arguments are passed through, and the +`-->` command can be trailed by flags: + +- quiet: Suppress unsupported keyword warnings +- require: Error if keyword is unsupported +- force: Don't allow user override for this keyword +""" +macro recipe(funcexpr::Expr) + func_signature, func_body = funcexpr.args + + if !(funcexpr.head in (:(=), :function)) + error("Must wrap a valid function call!") + end + if !(isa(func_signature, Expr) && func_signature.head in (:call, :where)) + error("Expected `func_signature = ...` with func_signature as a call or where Expr... got: $func_signature") + end + if length(func_signature.args) < 2 + error("Missing function arguments... need something to dispatch on!") + end + + args, kw_body, cleanup_body = create_kw_body(func_signature) + func = get_function_def(func_signature, args) + + # this is where the receipe func_body is processed + # replace all the key => value lines with argument setting logic + # and break up by series. + process_recipe_body!(func_body) + + # now build a function definition for apply_recipe, wrapping the return value in a tuple if needed. + # we are creating a vector of RecipeData objects, one per series. + funcdef = Expr(:function, func, esc(quote + @nospecialize + if RecipesBase._debug_recipes[1] + println("apply_recipe args: ", $args) + end + $kw_body + $cleanup_body + series_list = RecipesBase.RecipeData[] + func_return = $func_body + if func_return != nothing + push!(series_list, RecipesBase.RecipeData(plotattributes, RecipesBase.wrap_tuple(func_return))) + end + series_list + end)) + funcdef +end + + +# -------------------------------------------------------------------------- + +""" +Meant to be used inside a recipe to add additional RecipeData objects to the list: + +```julia +@recipe function f(::T) + # everything get this setting + linecolor --> :red + + @series begin + # this setting is only for this series + fillcolor := :green + + # return the args, just like in recipes + rand(10) + end + + # this is the main series... though it can be skipped by returning nothing. + # note: a @series block returns nothing + rand(100) +end +``` +""" +macro series(expr::Expr) + esc(quote + let plotattributes = copy(plotattributes) + args = $expr + push!(series_list, RecipesBase.RecipeData(plotattributes, RecipesBase.wrap_tuple(args))) + nothing + end + end) +end + +# -------------------------------------------------------------------------- + +""" +You can easily define your own plotting recipes with convenience methods: +```julia +@userplot GroupHist + +@recipe function f(gh::GroupHist) + # set some attributes, add some series, using gh.args as input +end +# now you can plot like: +grouphist(rand(1000,4)) +``` +""" +macro userplot(expr) + _userplot(expr) +end + +function _userplot(expr::Expr) + if expr.head != :struct + error("Must call userplot on a [mutable] struct expression. Got: $expr") + end + + typename = gettypename(expr.args[2]) + funcname = Symbol(lowercase(string(typename))) + funcname2 = Symbol(funcname, "!") + + # return a code block with the type definition and convenience plotting methods + esc(quote + $expr + export $funcname, $funcname2 + Core.@__doc__ $funcname(args...; kw...) = RecipesBase.plot($typename(args); kw...) + Core.@__doc__ $funcname2(args...; kw...) = RecipesBase.plot!($typename(args); kw...) + Core.@__doc__ $funcname2(plt::RecipesBase.AbstractPlot, args...; kw...) = RecipesBase.plot!(plt, $typename(args); kw...) + end) +end + +function _userplot(sym::Symbol) + _userplot(:(mutable struct $sym + args + end)) +end + +gettypename(sym::Symbol) = sym + +function gettypename(expr::Expr) + expr.head == :curly || @error "Unexpected struct name: $expr" + expr.args[1] +end + +#---------------------------------------------------------------------------- + +""" + @shorthands(funcname::Symbol) + +Defines and exports shorthand plotting method definitions (`\$funcname` and `\$funcname!`). +Pass the series type (as a symbol) to the macro. + +## Examples + +```julia +# define some series type +@recipe function f(::Type{Val{:myseriestype}}, x, y) + # some implementation here +end +# docstrings are forwarded +\"\"\" + myseriestype(x, y) +Plot my series type! +\"\"\" +@shorthands myseriestype +``` +""" +macro shorthands(funcname::Symbol) + funcname2 = Symbol(funcname, "!") + esc(quote + export $funcname, $funcname2 + Core.@__doc__ $funcname(args...; kw...) = RecipesBase.plot(args...; kw..., seriestype = $(Meta.quot(funcname))) + Core.@__doc__ $funcname2(args...; kw...) = RecipesBase.plot!(args...; kw..., seriestype = $(Meta.quot(funcname))) + end) +end + +#---------------------------------------------------------------------------- + +# allow usage of type recipes without depending on StatsPlots + +""" +`recipetype(s, args...)` + +Use this function to refer to type recipes by their symbol, without taking a dependency. + +# Example + +```julia +import RecipesBase: recipetype +recipetype(:groupedbar, 1:10, rand(10, 2)) +``` + +instead of + +```julia +import StatsPlots: GroupedBar +GroupedBar((1:10, rand(10, 2))) +``` +""" +recipetype(s, args...) = recipetype(Val(s), args...) + +function recipetype(s::Val{T}, args...) where T + error("No type recipe defined for type $T. You may need to load StatsPlots") +end + +# ---------------------------------------------------------------------- +# @layout macro + +function add_layout_pct!(kw::AKW, v::Expr, idx::Integer, nidx::Integer) + # dump(v) + # something like {0.2w}? + if v.head == :call && v.args[1] == :* + num = v.args[2] + if length(v.args) == 3 && isa(num, Number) + units = v.args[3] + if units == :h + return kw[:h] = num + elseif units == :w + return kw[:w] = num + elseif units in (:pct, :px, :mm, :cm, :inch) + idx == 1 && (kw[:w] = v) + (idx == 2 || nidx == 1) && (kw[:h] = v) + # return kw[idx == 1 ? :w : :h] = v + end + end + end + error("Couldn't match layout curly (idx=$idx): $v") +end + +function add_layout_pct!(kw::AKW, v::Number, idx::Integer) + # kw[idx == 1 ? :w : :h] = v*pct + idx == 1 && (kw[:w] = v) + (idx == 2 || nidx == 1) && (kw[:h] = v) +end + +isrow(v) = isa(v, Expr) && v.head in (:hcat, :row) +iscol(v) = isa(v, Expr) && v.head == :vcat +rowsize(v) = isrow(v) ? length(v.args) : 1 + +function create_grid(expr::Expr) + if iscol(expr) + create_grid_vcat(expr) + elseif isrow(expr) + :( + let cell = Matrix(undef, 1, $(length(expr.args))) + $( + [ + :(cell[1, $i] = $(create_grid(v))) for + (i, v) in enumerate(expr.args) + ]... + ) + cell + end + ) + + elseif expr.head == :curly + create_grid_curly(expr) + else + # if it's something else, just return that (might be an existing layout?) + esc(expr) + end +end + +function create_grid_vcat(expr::Expr) + rowsizes = map(rowsize, expr.args) + rmin, rmax = extrema(rowsizes) + if rmin > 0 && rmin == rmax + # we have a grid... build the whole thing + # note: rmin is the number of columns + nr = length(expr.args) + nc = rmin + body = Expr(:block) + for r in 1:nr + arg = expr.args[r] + if isrow(arg) + for (c, item) in enumerate(arg.args) + push!(body.args, :(cell[$r, $c] = $(create_grid(item)))) + end + else + push!(body.args, :(cell[$r, 1] = $(create_grid(arg)))) + end + end + :( + let cell = Matrix(undef, $nr, $nc) + $body + cell + end + ) + else + # otherwise just build one row at a time + :( + let cell = Matrix(undef, $(length(expr.args)), 1) + $( + [ + :(cell[$i, 1] = $(create_grid(v))) for + (i, v) in enumerate(expr.args) + ]... + ) + cell + end + ) + end +end + +function create_grid_curly(expr::Expr) + kw = KW() + for (i, arg) in enumerate(expr.args[2:end]) + add_layout_pct!(kw, arg, i, length(expr.args) - 1) + end + s = expr.args[1] + if isa(s, Expr) && s.head == :call && s.args[1] == :grid + create_grid( + :(grid( + $(s.args[2:end]...), + width = $(get(kw, :w, QuoteNode(:auto))), + height = $(get(kw, :h, QuoteNode(:auto))), + )), + ) + elseif isa(s, Symbol) + :(( + label = $(QuoteNode(s)), + width = $(get(kw, :w, QuoteNode(:auto))), + height = $(get(kw, :h, QuoteNode(:auto))), + )) + else + error("Unknown use of curly brackets: $expr") + end +end + +function create_grid(s::Symbol) + :((label = $(QuoteNode(s)), blank = $(s == :_))) +end + +""" + @layout mat + +Generate the subplots layout from a matrix of symbols (where subplots can span multiple rows or columns). +Precise sizing can be achieved with curly brackets, otherwise the free space is equally split between the plot areas of subplots. +You can use the `_` character (underscore) to ignore plots in the layout (blank plots). + +# Examples + +```julia-repl +julia> @layout [a b; c] +2×1 Matrix{Any}: + Any[(label = :a, blank = false) (label = :b, blank = false)] + (label = :c, blank = false) + +julia> @layout [a{0.3w}; b{0.2h}] +2×1 Matrix{Any}: + (label = :a, width = 0.3, height = :auto) + (label = :b, width = :auto, height = 0.2) + +julia> @layout [_ ° _; ° ° °; ° ° °] +3×3 Matrix{Any}: + (label = :_, blank = true) … (label = :_, blank = true) + (label = :°, blank = false) (label = :°, blank = false) + (label = :°, blank = false) (label = :°, blank = false) + +``` +""" +macro layout(mat::Expr) + create_grid(mat) +end + +@precompile_setup begin + struct __RecipesBasePrecompileType end + @precompile_all_calls begin + @layout [a b; c] + @layout [a{0.3w}; b{0.2h}] + @layout [_ ° _; ° ° °; ° ° °] + # @userplot __RecipesBasePrecompileType # fails (export statements) + @recipe f(::__RecipesBasePrecompileType) = begin + @series begin + markershape --> :auto, :require + markercolor --> customcolor, :force + xrotation --> 5 + zrotation --> 6, :quiet + fillcolor := :green + ones(1) + end + zeros(1) + end + end +end + +end diff --git a/RecipesBase/test/runtests.jl b/RecipesBase/test/runtests.jl new file mode 100644 index 000000000..9ba9272f0 --- /dev/null +++ b/RecipesBase/test/runtests.jl @@ -0,0 +1,147 @@ +# Run tests with `import RecipesBase` instead of `using RecipesBase` to test +# that objects like `AbstractPlot` are properly prefixed with `RecipesBase.` in +# the macros. +import RecipesBase +using Test, Random + +const KW = Dict{Symbol, Any} + +RecipesBase.is_key_supported(k::Symbol) = true + +for t in [Symbol(:T, i) for i in 1:5] + @eval struct $t end +end + + +@testset "@recipe" begin + + +""" +make sure the attribute dictionary was populated correctly, +and the returned arguments are as expected +""" +function check_apply_recipe(T::DataType, expect) + # this is similar to how Plots would call the method + plotattributes = KW(:customcolor => :red) + + data_list = RecipesBase.apply_recipe(plotattributes, T(), 2) + @test data_list[1].args == (Random.seed!(1); (rand(10,2),)) + @test plotattributes == expect +end + + +@testset "simple parametric type" begin + @test_throws MethodError RecipesBase.apply_recipe(KW(), T1()) + + RecipesBase.@recipe function plot(t::T1, n::N = 1; customcolor = :green) where N <: Integer + :markershape --> :auto, :require + :markercolor --> customcolor, :force + :xrotation --> 5 + :zrotation --> 6, :quiet + rand(10, n) + end + + Random.seed!(1) + check_apply_recipe(T1, KW( + :customcolor => :red, + :markershape => :auto, + :markercolor => :red, + :xrotation => 5, + :zrotation => 6)) +end + + +@testset "parametric type with where" begin + @test_throws MethodError RecipesBase.apply_recipe(KW(), T2()) + + RecipesBase.@recipe function plot(t::T2, n::N = 1; customcolor = :green) where {N <: Integer} + :markershape --> :auto, :require + :markercolor --> customcolor, :force + :xrotation --> 5 + :zrotation --> 6, :quiet + rand(10, n) + end + + Random.seed!(1) + check_apply_recipe(T2, KW( + :customcolor => :red, + :markershape => :auto, + :markercolor => :red, + :xrotation => 5, + :zrotation => 6)) +end + + +@testset "parametric type with double where" begin + @test_throws MethodError RecipesBase.apply_recipe(KW(), T3()) + + RecipesBase.@recipe function plot( + t::T3, n::N = 1, m::M = 0.0; customcolor = :green + ) where {N <: Integer} where {M <: Float64} + :markershape --> :auto, :require + :markercolor --> customcolor, :force + :xrotation --> 5 + :zrotation --> 6, :quiet + rand(10, n) + end + + Random.seed!(1) + check_apply_recipe(T3, KW( + :customcolor => :red, + :markershape => :auto, + :markercolor => :red, + :xrotation => 5, + :zrotation => 6)) +end + + +@testset "manual access of plotattributes" begin + @test_throws MethodError RecipesBase.apply_recipe(KW(), T4()) + + RecipesBase.@recipe function plot(t::T4, n = 1; customcolor = :green) + :markershape --> :auto, :require + :markercolor --> customcolor, :force + :xrotation --> 5 + :zrotation --> 6, :quiet + plotattributes[:hello] = "hi" + plotattributes[:world] = "world" + rand(10,n) + end + Random.seed!(1) + check_apply_recipe(T4, KW( + :customcolor => :red, + :markershape => :auto, + :markercolor => :red, + :xrotation => 5, + :zrotation => 6, + :hello => "hi", + :world => "world" + )) +end + +@testset "no force" begin + @test_throws MethodError RecipesBase.apply_recipe(KW(), T5()) + + RecipesBase.@recipe function plot(t::T5, n::Integer = 1) + customcolor --> :notred + rand(10, n) + end + + Random.seed!(1) + check_apply_recipe(T5, KW(:customcolor => :red)) +end + + +end # @testset "@recipe" + +# Can't do this inside a test-set, because it creates a struct. +RecipesBase.@userplot MyPlot + +@testset "@userplot" begin + @test typeof(myplot) <: Function + @test length(methods(myplot)) == 1 + @test typeof(myplot!) <: Function + @test length(methods(myplot!)) == 2 + m = MyPlot(:my_arg) + @test m.args == :my_arg +end diff --git a/RecipesPipeline/LICENSE b/RecipesPipeline/LICENSE new file mode 100644 index 000000000..40d57c76f --- /dev/null +++ b/RecipesPipeline/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020 Michael Krabbe Borregaard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/RecipesPipeline/Project.toml b/RecipesPipeline/Project.toml new file mode 100644 index 000000000..6a38c7ccf --- /dev/null +++ b/RecipesPipeline/Project.toml @@ -0,0 +1,23 @@ +name = "RecipesPipeline" +uuid = "01d81517-befc-4cb6-b9ec-a95719d0359c" +authors = ["Michael Krabbe Borregaard "] +version = "0.6.6" + +[deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +NaNMath = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" +PlotUtils = "995b91a9-d308-5afd-9ec6-746e21dbc043" +RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" + +[compat] +NaNMath = "0.3, 1" +PlotUtils = "0.6.5, 1" +RecipesBase = "0.8, 1" +julia = "1.6" + +[extras] +BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["BenchmarkTools", "Test"] diff --git a/RecipesPipeline/README.md b/RecipesPipeline/README.md new file mode 100644 index 000000000..89518ef94 --- /dev/null +++ b/RecipesPipeline/README.md @@ -0,0 +1,38 @@ +# RecipesPipeline +[travis-img]: https://travis-ci.com/JuliaPlots/RecipesPipeline.jl.svg?branch=master +[travis-url]: https://travis-ci.com/JuliaPlots/RecipesPipeline.jl + +[docs-img]: https://img.shields.io/badge/docs-dev-blue.svg +[docs-url]: http://juliaplots.org/RecipesPipeline.jl/dev/ + +[![][travis-img]][travis-url] +[![][docs-img]][docs-url] +[![Codecov](https://codecov.io/gh/JuliaPlots/RecipesPipeline.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/JuliaPlots/RecipesPipeline.jl) +[![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://julialang.zulipchat.com/#narrow/stream/236493-plots) + +#### An implementation of the recipe pipeline from Plots +This package was factored out of Plots.jl to allow any other plotting package to use the recipe pipeline. In short, the extremely lightweight RecipesBase.jl package can be depended on by any package to define "recipes": plot specifications of user-defined types, as well as custom plot types. RecipePipeline.jl contains the machinery to translate these recipes to full specifications for a plot. + +The package is intended to be used by consumer plotting packages, and is currently used by [Plots.jl](https://github.com/JuliaPlots/Plots.jl) (v.1.1.0 and above) and [MakieRecipes.jl](https://github.com/JuliaPlots/Makie.jl/tree/master/MakieRecipes), a package that bridges RecipesBase recipes to [Makie.jl](https://github.com/JuliaPlots/Makie.jl). + +Current functionality: +```julia +using RecipesBase + +# Our user-defined data type +struct T end + +RecipesBase.@recipe function plot(::T, n = 1; customcolor = :green) + seriestype --> :scatter + markershape --> :auto # if markershape is unset, make it :auto + markercolor := customcolor # force markercolor to be customcolor + xrotation --> 45 # if xrotation is unset, make it 45 + zrotation --> 90 # if zrotation is unset, make it 90 + rand(10,n) # return the arguments (input data) for the next recipe +end + +using Makie, MakieRecipes +recipeplot(T(), 3; markersize = 3) + +``` +Screenshot 2020-04-05 at 16 36 46 diff --git a/RecipesPipeline/deps/SnoopCompile/precompile/1.6/precompile_RecipesPipeline.jl b/RecipesPipeline/deps/SnoopCompile/precompile/1.6/precompile_RecipesPipeline.jl new file mode 100644 index 000000000..4a9bbefd0 --- /dev/null +++ b/RecipesPipeline/deps/SnoopCompile/precompile/1.6/precompile_RecipesPipeline.jl @@ -0,0 +1,52 @@ +# Use +# @warnpcfail precompile(args...) +# if you want to be warned when a precompile directive fails +macro warnpcfail(ex::Expr) + modl = __module__ + file = __source__.file === nothing ? "?" : String(__source__.file) + line = __source__.line + quote + $(esc(ex)) || @warn """precompile directive + $($(Expr(:quote, ex))) + failed. Please report an issue in $($modl) (after checking for duplicates) or remove this directive.""" _file=$file _line=$line + end +end + + +function _precompile_() + ccall(:jl_generating_output, Cint, ()) == 1 || return nothing + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractMatrix{T} where T}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractVector{T} where T,AbstractVector{T} where T,Function}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Function,Number,Number}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},GroupBy,Any,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},GroupBy,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Type{SliceIt},Any,Any,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Vector{Function},Number,Number}) + Base.precompile(Tuple{typeof(_apply_type_recipe),Any,AbstractArray,Any}) + Base.precompile(Tuple{typeof(_apply_type_recipe),Any,Surface,Any}) + Base.precompile(Tuple{typeof(_compute_xyz),Vector{Float64},Function,Nothing,Bool}) + Base.precompile(Tuple{typeof(_extract_group_attributes),Vector{String}}) + Base.precompile(Tuple{typeof(_map_funcs),Function,StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}}}) + Base.precompile(Tuple{typeof(_prepare_series_data),Matrix{Union{Missing, Float64}}}) + Base.precompile(Tuple{typeof(_process_seriesrecipe),Any,Any}) + Base.precompile(Tuple{typeof(_process_seriesrecipes!),Any,Any}) + Base.precompile(Tuple{typeof(_scaled_adapted_grid),Function,Symbol,Symbol,Float64,Irrational{:π}}) + Base.precompile(Tuple{typeof(_series_data_vector),Int64,Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Matrix{Float64},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Surface{Matrix{Int64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{AbstractVector{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Function},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Int64},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Real},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{String},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Union{Missing, Int64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Vector{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Vector{T} where T},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(recipe_pipeline!),Any,Any,Any}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Float64, Float64, Float64}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Int64, Int64}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Int64, Real}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Vector{Float64}, Vector{Float64}}}}) + isdefined(RecipesPipeline, Symbol("#11#12")) && Base.precompile(Tuple{getfield(RecipesPipeline, Symbol("#11#12")),Int64}) +end diff --git a/RecipesPipeline/deps/SnoopCompile/precompile/1.7/precompile_RecipesPipeline.jl b/RecipesPipeline/deps/SnoopCompile/precompile/1.7/precompile_RecipesPipeline.jl new file mode 100644 index 000000000..008136e2c --- /dev/null +++ b/RecipesPipeline/deps/SnoopCompile/precompile/1.7/precompile_RecipesPipeline.jl @@ -0,0 +1,108 @@ +# Use +# @warnpcfail precompile(args...) +# if you want to be warned when a precompile directive fails +macro warnpcfail(ex::Expr) + modl = __module__ + file = __source__.file === nothing ? "?" : String(__source__.file) + line = __source__.line + quote + $(esc(ex)) || @warn """precompile directive + $($(Expr(:quote, ex))) + failed. Please report an issue in $($modl) (after checking for duplicates) or remove this directive.""" _file=$file _line=$line + end +end + + +const __bodyfunction__ = Dict{Method,Any}() + +# Find keyword "body functions" (the function that contains the body +# as written by the developer, called after all missing keyword-arguments +# have been assigned values), in a manner that doesn't depend on +# gensymmed names. +# `mnokw` is the method that gets called when you invoke it without +# supplying any keywords. +function __lookup_kwbody__(mnokw::Method) + function getsym(arg) + isa(arg, Symbol) && return arg + @assert isa(arg, GlobalRef) + return arg.name + end + + f = get(__bodyfunction__, mnokw, nothing) + if f === nothing + fmod = mnokw.module + # The lowered code for `mnokw` should look like + # %1 = mkw(kwvalues..., #self#, args...) + # return %1 + # where `mkw` is the name of the "active" keyword body-function. + ast = Base.uncompressed_ast(mnokw) + if isa(ast, Core.CodeInfo) && length(ast.code) >= 2 + callexpr = ast.code[end-1] + if isa(callexpr, Expr) && callexpr.head == :call + fsym = callexpr.args[1] + if isa(fsym, Symbol) + f = getfield(fmod, fsym) + elseif isa(fsym, GlobalRef) + if fsym.mod === Core && fsym.name === :_apply + f = getfield(mnokw.module, getsym(callexpr.args[2])) + elseif fsym.mod === Core && fsym.name === :_apply_iterate + f = getfield(mnokw.module, getsym(callexpr.args[3])) + else + f = getfield(fsym.mod, fsym.name) + end + else + f = missing + end + else + f = missing + end + else + f = missing + end + __bodyfunction__[mnokw] = f + end + return f +end + +function _precompile_() + ccall(:jl_generating_output, Cint, ()) == 1 || return nothing + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractMatrix}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractVector,AbstractVector,Function}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractVector{<:Tuple}}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Function,Number,Number}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},GroupBy,Any,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},GroupBy,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Type{SliceIt},Any,Any,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Vector{Function},Number,Number}) + Base.precompile(Tuple{typeof(_apply_type_recipe),Any,AbstractArray,Any}) + Base.precompile(Tuple{typeof(_apply_type_recipe),Any,Surface,Any}) + Base.precompile(Tuple{typeof(_compute_xyz),Vector{Float64},Function,Nothing,Bool}) + Base.precompile(Tuple{typeof(_compute_xyz),Vector{String},Vector{String},Nothing,Bool}) + Base.precompile(Tuple{typeof(_extract_group_attributes),Vector{String}}) + Base.precompile(Tuple{typeof(_prepare_series_data),Matrix{Union{Missing, Float64}}}) + Base.precompile(Tuple{typeof(_process_seriesrecipe),Any,Any}) + Base.precompile(Tuple{typeof(_process_seriesrecipes!),Any,Any}) + Base.precompile(Tuple{typeof(_scaled_adapted_grid),Function,Symbol,Symbol,Float64,Irrational{:π}}) + Base.precompile(Tuple{typeof(_series_data_vector),Int64,Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Matrix{Float64},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Surface{Matrix{Int64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{AbstractVector{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Function},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Int64},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Real},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{String},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Union{Missing, Int64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Vector{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Vector},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(recipe_pipeline!),Any,Any,Any}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Float64, Float64, Float64}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Int64, Int64}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Int64, Real}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Vector{Float64}, Vector{Float64}}}}) + isdefined(RecipesPipeline, Symbol("#11#12")) && Base.precompile(Tuple{getfield(RecipesPipeline, Symbol("#11#12")),Int64}) + let fbody = try __lookup_kwbody__(which(_extract_group_attributes, (Vector{String},Vector{Float64},))) catch missing end + if !ismissing(fbody) + precompile(fbody, (Function,typeof(_extract_group_attributes),Vector{String},Vector{Float64},)) + end +end +end diff --git a/RecipesPipeline/deps/SnoopCompile/precompile/precompile_RecipesPipeline.jl b/RecipesPipeline/deps/SnoopCompile/precompile/precompile_RecipesPipeline.jl new file mode 100644 index 000000000..0d2878f88 --- /dev/null +++ b/RecipesPipeline/deps/SnoopCompile/precompile/precompile_RecipesPipeline.jl @@ -0,0 +1,48 @@ +# Use +# @warnpcfail precompile(args...) +# if you want to be warned when a precompile directive fails +macro warnpcfail(ex::Expr) + modl = __module__ + file = __source__.file === nothing ? "?" : String(__source__.file) + line = __source__.line + quote + $(esc(ex)) || @warn """precompile directive + $($(Expr(:quote, ex))) + failed. Please report an issue in $($modl) (after checking for duplicates) or remove this directive.""" _file=$file _line=$line + end +end + + +function _precompile_() + ccall(:jl_generating_output, Cint, ()) == 1 || return nothing + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractMatrix{T} where T}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},AbstractVector{T} where T,AbstractVector{T} where T,Function}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Function,Number,Number}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},GroupBy,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Type{SliceIt},Any,Any,Any}) + Base.precompile(Tuple{typeof(RecipesBase.apply_recipe),AbstractDict{Symbol, Any},Vector{Function},Number,Number}) + Base.precompile(Tuple{typeof(_apply_type_recipe),Any,AbstractArray,Any}) + Base.precompile(Tuple{typeof(_apply_type_recipe),Any,Surface,Any}) + Base.precompile(Tuple{typeof(_compute_xyz),Vector{Float64},Function,Nothing,Bool}) + Base.precompile(Tuple{typeof(_extract_group_attributes),Vector{String},Vector{Float64}}) + Base.precompile(Tuple{typeof(_map_funcs),Function,StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}}}) + Base.precompile(Tuple{typeof(_process_seriesrecipe),Any,Any}) + Base.precompile(Tuple{typeof(_process_seriesrecipes!),Any,Any}) + Base.precompile(Tuple{typeof(_scaled_adapted_grid),Function,Symbol,Symbol,Float64,Irrational{:π}}) + Base.precompile(Tuple{typeof(_series_data_vector),Int64,Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Matrix{Float64},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Surface{Matrix{Int64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{AbstractVector{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Function},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Int64},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Real},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{String},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Union{Missing, Int64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Vector{Float64}},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(_series_data_vector),Vector{Vector{T} where T},Dict{Symbol, Any}}) + Base.precompile(Tuple{typeof(recipe_pipeline!),Any,Any,Any}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Float64, Float64}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Int64, Int64}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Int64, Real}}}) + Base.precompile(Tuple{typeof(unzip),Vector{Tuple{Vector{Float64}, Vector{Float64}}}}) +end diff --git a/RecipesPipeline/deps/SnoopCompile/snoop_bench.jl b/RecipesPipeline/deps/SnoopCompile/snoop_bench.jl new file mode 100644 index 000000000..30ec6f2c6 --- /dev/null +++ b/RecipesPipeline/deps/SnoopCompile/snoop_bench.jl @@ -0,0 +1,3 @@ +using CompileBot + +snoop_bench(BotConfig("RecipesPipeline")) diff --git a/RecipesPipeline/deps/SnoopCompile/snoop_bot.jl b/RecipesPipeline/deps/SnoopCompile/snoop_bot.jl new file mode 100644 index 000000000..772472946 --- /dev/null +++ b/RecipesPipeline/deps/SnoopCompile/snoop_bot.jl @@ -0,0 +1,9 @@ +using CompileBot + +botconfig = BotConfig( + "RecipesPipeline", + version = ["1.6", "1.7"], # <<< keep versions in sync with .github/workflows/SnoopCompile.yml + # else_version = "1.8", +) + +snoop_bot(botconfig) diff --git a/RecipesPipeline/src/RecipesPipeline.jl b/RecipesPipeline/src/RecipesPipeline.jl new file mode 100644 index 000000000..522508f16 --- /dev/null +++ b/RecipesPipeline/src/RecipesPipeline.jl @@ -0,0 +1,110 @@ +# # RecipesPipeline +module RecipesPipeline + +import RecipesBase +import RecipesBase: @recipe, @series, RecipeData, is_explicit +import PlotUtils # tryrange and adapted_grid +using Dates +using NaNMath + +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, + dateformatter, + datetimeformatter, + timeformatter, + explicitkeys, + defaultkeys +# 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!, + process_sliced_series_attributes! + +include("utils.jl") +include("api.jl") +include("series.jl") +include("group.jl") +include("user_recipe.jl") +include("type_recipe.jl") +include("plot_recipe.jl") +include("series_recipe.jl") +include("recipes.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. Then, add all series to the plot +object `plt` and return it. +""" +function recipe_pipeline!(plt, plotattributes, args) + @nospecialize + 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 + +include("precompile_includer.jl") + +end diff --git a/RecipesPipeline/src/api.jl b/RecipesPipeline/src/api.jl new file mode 100644 index 000000000..af01c3d46 --- /dev/null +++ b/RecipesPipeline/src/api.jl @@ -0,0 +1,217 @@ +# # API + +# ## 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::AKW, recipe_type::Symbol, @nospecialize(args)) end +function warn_on_recipe_aliases!(plt, v::AbstractVector, recipe_type::Symbol, @nospecialize(args)) + for x in v + warn_on_recipe_aliases!(plt, x, recipe_type, args) + end +end +function warn_on_recipe_aliases!(plt, rd::RecipeData, recipe_type::Symbol, @nospecialize(args)) + warn_on_recipe_aliases!(plt, rd.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, len) = + all(v -> splittable_attribute(plt, key, v, len), val) + + +""" + 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(plt, 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 RecipesPipeline? +""" + 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 RecipesPipeline? +""" + 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 + +# ### processing of axis args +# axis args before type recipes should still be mapped to all axes +""" + preprocess_axis_args!(plt, plotattributes) + +Preprocessing of axis attributes. +Prepends the axis letter to axis attributes by default. +""" +function preprocess_axis_args!(plt, plotattributes) + 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 + +""" + preprocess_axis_args!(plt, plotattributes, letter) + +This version additionally stores the letter name in `plotattributes[:letter]`. +""" +function preprocess_axis_args!(plt, plotattributes, letter) + plotattributes[:letter] = letter + preprocess_axis_args!(plt, plotattributes) +end + +# axis args in type recipes should only be applied to the current axis +""" + postprocess_axis_args!(plt, plotattributes, letter) + +Removes the `:letter` key from `plotattributes` and does the same prepending of the letters as `preprocess_axis_args!`. +""" +function postprocess_axis_args!(plt, plotattributes, letter) + 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 + +# ## 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) = throw(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 + +""" + process_sliced_series_attributes!(plt, kw_list) + +All series attributes are now properly resolved. Any change of the `kw_list` before the application of recipes must come here. +""" +function process_sliced_series_attributes!(plt, kw_list) 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 + +""" + is_key_supported(key) + +Check if the plotting package natively supports the attribute `key` +""" +RecipesBase.is_key_supported(key) = true + +# ## Finalizer + +""" + 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/group.jl b/RecipesPipeline/src/group.jl new file mode 100644 index 000000000..6acfbe6f5 --- /dev/null +++ b/RecipesPipeline/src/group.jl @@ -0,0 +1,128 @@ +# # Grouping + +"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) + res = Dict{eltype(v),Vector{Int}}() + for (i,label) in enumerate(v) + if haskey(res,label) + push!(res[label],i) + else + res[label] = [i] + end + end + group_labels = sort(collect(keys(res))) + group_indices = [res[i] for i 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_attribute(plt, 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/plot_recipe.jl b/RecipesPipeline/src/plot_recipe.jl new file mode 100644 index 000000000..5f6916094 --- /dev/null +++ b/RecipesPipeline/src/plot_recipe.jl @@ -0,0 +1,48 @@ +# Plot Recipes + +@nospecialize + +""" + _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 + st = kw[:seriestype] + st = kw[:seriestype] = type_alias(plt, st) + datalist = RecipesBase.apply_recipe(kw, Val{st}, plt) + if !isnothing(datalist) + 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 + else + push!(kw_list, kw) + end + return +end + +@specialize diff --git a/RecipesPipeline/src/precompile_includer.jl b/RecipesPipeline/src/precompile_includer.jl new file mode 100644 index 000000000..4c740bf62 --- /dev/null +++ b/RecipesPipeline/src/precompile_includer.jl @@ -0,0 +1,41 @@ +should_precompile = true + + +# Don't edit the following! Instead change the script for `snoop_bot`. +ismultios = false +ismultiversion = true +# precompile_enclosure +@static if !should_precompile + # nothing +elseif !ismultios && !ismultiversion + @static if isfile( + joinpath(@__DIR__, "../deps/SnoopCompile/precompile/precompile_RecipesPipeline.jl"), + ) + include("../deps/SnoopCompile/precompile/precompile_RecipesPipeline.jl") + _precompile_() + end +else + @static if v"1.6.0-DEV" <= VERSION <= v"1.6.9" + @static if isfile( + joinpath( + @__DIR__, + "../deps/SnoopCompile/precompile//1.6/precompile_RecipesPipeline.jl", + ), + ) + include("../deps/SnoopCompile/precompile//1.6/precompile_RecipesPipeline.jl") + _precompile_() + end + elseif v"1.7.0-DEV" <= VERSION <= v"1.7.9" + @static if isfile( + joinpath( + @__DIR__, + "../deps/SnoopCompile/precompile//1.7/precompile_RecipesPipeline.jl", + ), + ) + include("../deps/SnoopCompile/precompile//1.7/precompile_RecipesPipeline.jl") + _precompile_() + end + else + end + +end # precompile_enclosure diff --git a/RecipesPipeline/src/recipes.jl b/RecipesPipeline/src/recipes.jl new file mode 100644 index 000000000..2672f9c1b --- /dev/null +++ b/RecipesPipeline/src/recipes.jl @@ -0,0 +1,36 @@ +# # Default recipes +# Includes stuff from Base/stdlib. +# ------------------------------------------------- +# ## Dates & Times + +function epochdays2datetime(fractionaldays::Real)::DateTime + days = floor(fractionaldays) + dayfraction = fractionaldays - days + missing_ms = Millisecond(round(Millisecond(Day(1)).value * dayfraction)) + DateTime(Dates.epochdays2date(days)) + missing_ms +end + +epochdays2epochms(x) = Dates.datetime2epochms(epochdays2datetime(x)) + +function dateformatter(dt::Integer) + string(Date(Dates.UTD(dt))) +end + +function dateformatter(dt::Real) + string(DateTime(Dates.UTM(epochdays2epochms(dt)))) +end + +datetimeformatter(dt) = string(DateTime(Dates.UTM(round(dt)))) +timeformatter(t) = string(Dates.Time(Dates.Nanosecond(round(t)))) + +@recipe f(::Type{Date}, dt::Date) = (dt -> Dates.value(dt), dateformatter) +@recipe f(::Type{DateTime}, dt::DateTime) = + (dt -> Dates.value(dt), datetimeformatter) +@recipe f(::Type{Dates.Time}, t::Dates.Time) = (t -> Dates.value(t), timeformatter) +@recipe f(::Type{P}, t::P) where {P<:Dates.Period} = + (t -> Dates.value(t), t -> string(P(round(t)))) + +# ------------------------------------------------- +# ## Characters + +@recipe f(::Type{<:AbstractChar}, ::AbstractChar) = (string, string) diff --git a/RecipesPipeline/src/series.jl b/RecipesPipeline/src/series.jl new file mode 100644 index 000000000..2adcd4ec5 --- /dev/null +++ b/RecipesPipeline/src/series.jl @@ -0,0 +1,153 @@ +# # Series handling + +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{T}) where {T<:MaybeNumber} + # Get a non-missing AbstractFloat type for the array + # There may be a better way to do this? + F = typeof(float(zero(nonmissingtype(T)))) + # Create a new array with this type to write to + float_a = similar(a, F) + # Replace missing and inf values with NaN + broadcast!(float_a, a) do x + ismissing(x) || isinf(x) ? NaN : x + end + return 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 + + +# -------------------------------------------------------------------- + +_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, nice_error=false) + x = _compute_x(x, y, z) + y = _compute_y(x, y, z) + z = _compute_z(x, y, z) + if nice_error && isnothing(z) # don't touch 3D plots + n = size(x,1) + !isnothing(y) && size(y,1) != n && error("Expects $n elements in each col of y, found $(size(y,1)).") + end + _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) + @nospecialize + nice_error = (x isa AbstractVector) && (y isa AbstractMatrix) # only check in the trivial case + # 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) + + + 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, nice_error) + + 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..f799a9192 --- /dev/null +++ b/RecipesPipeline/src/series_recipe.jl @@ -0,0 +1,70 @@ +# # Series Recipes + +@nospecialize + +""" + _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) + end + process_sliced_series_attributes!(plt, kw_list) + for kw in kw_list + 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 + +@specialize diff --git a/RecipesPipeline/src/type_recipe.jl b/RecipesPipeline/src/type_recipe.jl new file mode 100644 index 000000000..97cda682a --- /dev/null +++ b/RecipesPipeline/src/type_recipe.jl @@ -0,0 +1,76 @@ +# # Type Recipes + +@nospecialize + +# 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) + plt = plotattributes[:plot_object] + preprocess_axis_args!(plt, plotattributes, letter) + rdvec = RecipesBase.apply_recipe(plotattributes, typeof(v), v) + warn_on_recipe_aliases!(plotattributes[:plot_object], plotattributes, :type, v) + postprocess_axis_args!(plt, 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!(plt, 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, 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, x) + postprocess_axis_args!(plt, 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!(plt, plotattributes, letter) + return w +end + +# special handling for Surface... need to properly unwrap and re-wrap +_apply_type_recipe( + plotattributes, + v::Surface{<:AMat{<:Union{AbstractFloat, Integer, AbstractString, Missing}}}, + letter, +) = v +function _apply_type_recipe(plotattributes, v::Surface, letter) + ret = _apply_type_recipe(plotattributes, v.surf, letter) + if typeof(ret) <: Formatted + Formatted(Surface(ret.data), ret.formatter) + else + Surface(ret) + end +end + +# don't do anything for datapoints or nothing +_apply_type_recipe( + plotattributes, + v::AbstractArray{<:Union{AbstractFloat, Integer, AbstractString, Missing}}, + letter, +) = v +_apply_type_recipe(plotattributes, v::Nothing, letter) = v + +@specialize diff --git a/RecipesPipeline/src/user_recipe.jl b/RecipesPipeline/src/user_recipe.jl new file mode 100644 index 000000000..35880a7da --- /dev/null +++ b/RecipesPipeline/src/user_recipe.jl @@ -0,0 +1,355 @@ +# # User Recipes + +""" + _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) + @nospecialize + still_to_process = _recipedata_vector(plt, plotattributes, args) + + # For plotting recipes, we swap out the args and update the parameter dictionary. We are keeping a stack of series that still need to be processed. + # + # On 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`, and 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) + @nospecialize + 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) + @nospecialize + 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 +# -------------------------------- + +@nospecialize + +# 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 function f(d::AbstractDict) + seriestype --> :line + collect(keys(d)), collect(values(d)) +end +# 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.(vec(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) + +# 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] + +# list of NamedTuples +@recipe function f(ntv::AVec{<:NamedTuple{K, Tuple{S, T}}}) where {K, S, T} + xguide --> string(K[1]) + yguide --> string(K[2]) + return Tuple.(ntv) +end +@recipe function f(ntv::AVec{<:NamedTuple{K, Tuple{R, S, T}}}) where {K, R, S, T} + xguide --> string(K[1]) + yguide --> string(K[2]) + zguide --> string(K[3]) + return Tuple.(ntv) +end + +@specialize + +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 diff --git a/RecipesPipeline/src/utils.jl b/RecipesPipeline/src/utils.jl new file mode 100644 index 000000000..dfeecda57 --- /dev/null +++ b/RecipesPipeline/src/utils.jl @@ -0,0 +1,238 @@ +# # Utilities + +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) + return dd +end +Base.length(dd::DefaultsDict) = length(union(keys(dd.explicit), keys(dd.defaults))) +function Base.iterate(dd::DefaultsDict) + key_list = union!(collect(keys(dd.explicit)), keys(dd.defaults)) + 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 +function reset_kw!(dd::DefaultsDict, k) + is_explicit(dd, k) && delete!(dd.explicit, k) + return dd +end +# 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) + +explicitkeys(dd::DefaultsDict) = keys(dd.explicit) +defaultkeys(dd::DefaultsDict) = keys(dd.defaults) + +# -------------------------------- +# ## 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, :iterate) + @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, :axes, :iterate) + @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, + :mesh3d +) + @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, + :mesh3d +) + @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 => NaNMath.log10, :log2 => NaNMath.log2, :ln => NaNMath.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 +# -------------------------------- + +unzip(v::AVec{<:Tuple}) = map(x->getfield.(v, x), fieldnames(eltype(v))) + +# -------------------------------- +# ## 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] + + +# -------------------------------- +# ## Signature strings +# -------------------------------- + +@nospecialize + +function userrecipe_signature_string(args) + return string("(::", join(string.(typeof.(args)), ", ::"), ")") +end +typerecipe_signature_string(::T) where T = "(::Type{$T}, ::$T)" +plotrecipe_signature_string(st) = "(::Type{Val{:$st}}, ::AbstractPlot)" +seriesrecipe_signature_string(st) = "(::Type{Val{:$st}}, x, y, z)" + +@specialize diff --git a/RecipesPipeline/test/runtests.jl b/RecipesPipeline/test/runtests.jl new file mode 100644 index 000000000..fa91ba169 --- /dev/null +++ b/RecipesPipeline/test/runtests.jl @@ -0,0 +1,37 @@ +using RecipesPipeline +using BenchmarkTools +using Test + +import RecipesPipeline: _prepare_series_data + +@testset "_prepare_series_data" begin + @test_throws ErrorException _prepare_series_data(:test) + @test _prepare_series_data(nothing) === nothing + @test _prepare_series_data((1.0, 2.0)) === (1.0, 2.0) + @test _prepare_series_data(identity) === identity + @test _prepare_series_data(1:5:10) === 1:5:10 + a = ones(Union{Missing,Float64}, 100, 100); + sd = _prepare_series_data(a) + @test sd == a + @test eltype(sd) == Float64 + a .= missing + sd = _prepare_series_data(a) + @test eltype(sd) == Float64 + @test all(isnan, sd) + a = fill(missing, 100, 100) + sd = _prepare_series_data(a) + @test eltype(sd) == Float64 + @test all(isnan, sd) + # TODO String, Volume etc +end + +@testset "unzip" begin + x, y, z = unzip([(1., 2., 3.), (1., 2., 3.)]) + @test all(x .== 1.) && all(y .== 2.) && all(z .== 3.) + x, y, z = unzip(Tuple{Float64, Float64, Float64}[]) + @test isempty(x) && isempty(y) && isempty(z) +end + +@testset "group" begin + include("test_group.jl") +end diff --git a/RecipesPipeline/test/test_group.jl b/RecipesPipeline/test/test_group.jl new file mode 100644 index 000000000..d2bd83c32 --- /dev/null +++ b/RecipesPipeline/test/test_group.jl @@ -0,0 +1,40 @@ +function _extract_group_attributes_old_slow_known_good_implementation(v, args...; legend_entry = string) + group_labels = collect(unique(sort(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] + RecipesPipeline.GroupBy(map(legend_entry, group_labels), group_indices) +end + +sc = [ "C","C","C","A", "A", "A","B","B","D"] +mc = rand([ "xx"*"$(i%6)" for i in 1:6],300) +mp = rand([ "xx"*"$(i%73)" for i in 1:73],1000) +lp = [ "xx"*"$(i%599)" for i in 1:2000] + +@testset "All" begin + @testset "Correctness" begin + res1 = _extract_group_attributes_old_slow_known_good_implementation(sc) + res2 = RecipesPipeline._extract_group_attributes(sc) + @test res1.group_labels == res2.group_labels + @test res1.group_indices == res2.group_indices + end + @testset "Correctness (medium)" begin + res1 = _extract_group_attributes_old_slow_known_good_implementation(mc) + res2 = RecipesPipeline._extract_group_attributes(mc) + @test res1.group_labels == res2.group_labels + @test res1.group_indices == res2.group_indices + end + @testset "Performance (medium)" begin + t1 = @benchmark res1 = _extract_group_attributes_old_slow_known_good_implementation(mp) + t2 = @benchmark res2 = RecipesPipeline._extract_group_attributes(mp) + @test !BenchmarkTools.isregression(judge(median(t2),median(t1))) + end + @testset "Performance (large ish)" begin + t1 = @benchmark res1 = _extract_group_attributes_old_slow_known_good_implementation(lp) + t2 = @benchmark res2 = RecipesPipeline._extract_group_attributes(lp) + @test BenchmarkTools.isimprovement(judge(median(t2),median(t1))) + end +end +