From 52ec432cfad401f48999ac9f807189ee05d94b70 Mon Sep 17 00:00:00 2001 From: Andy Nowacki Date: Wed, 25 Aug 2021 12:40:54 +0100 Subject: [PATCH] Plotly: Enable specified contour values for ranges; warn otherwise (#3757) --- src/arg_desc.jl | 2 +- src/args.jl | 25 ++++++++++++++++ src/backends/plotly.jl | 25 +++++++++++++++- src/backends/pyplot.jl | 2 -- test/runtests.jl | 2 ++ test/test_contours.jl | 68 ++++++++++++++++++++++++++++++++++++++++++ test/test_plotly.jl | 57 +++++++++++++++++++++++++++++++++++ 7 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 test/test_contours.jl create mode 100644 test/test_plotly.jl diff --git a/src/arg_desc.jl b/src/arg_desc.jl index da250471..af85b46d 100644 --- a/src/arg_desc.jl +++ b/src/arg_desc.jl @@ -30,7 +30,7 @@ const _arg_desc = KW( :marker_z => "AbstractVector, Function `f(x,y,z) -> z_value`, or Function `f(x,y) -> z_value`, or nothing. z-values for each series data point, which correspond to the color to be used from a markercolor gradient.", :line_z => "AbstractVector, Function `f(x,y,z) -> z_value`, or Function `f(x,y) -> z_value`, or nothing. z-values for each series line segment, which correspond to the color to be used from a linecolor gradient. Note that for N points, only the first N-1 values are used (one per line-segment).", :fill_z => "Matrix{Float64} of the same size as z matrix, which specifies the color of the 3D surface; the default value is `nothing`.", - :levels => "Integer, NTuple{2,Integer}, or AbstractVector. Levels or number of levels (or x-levels/y-levels) for a contour type.", + :levels => "Integer (number of contours) or AbstractVector (contour values). Determines contour levels for a contour type.", :orientation => "Symbol. Horizontal or vertical orientation for bar types. Values `:h`, `:hor`, `:horizontal` correspond to horizontal (sideways, anchored to y-axis), and `:v`, `:vert`, and `:vertical` correspond to vertical (the default).", :bar_position => "Symbol. Choose from `:overlay` (default), `:stack`. (warning: May not be implemented fully)", :bar_width => "nothing or Number. Width of bars in data coordinates. When nothing, chooses based on x (or y when `orientation = :h`).", diff --git a/src/args.jl b/src/args.jl index 57dd3298..f2eeb4c5 100644 --- a/src/args.jl +++ b/src/args.jl @@ -1502,6 +1502,11 @@ function RecipesPipeline.preprocess_attributes!(plotattributes::AKW) plotattributes[:framestyle] = _framestyleAliases[plotattributes[:framestyle]] end + # contours + if haskey(plotattributes, :levels) + check_contour_levels(plotattributes[:levels]) + end + # warnings for moved recipes st = get(plotattributes, :seriestype, :path) if st in (:boxplot, :violin, :density) && !isdefined(Main, :StatsPlots) @@ -1629,6 +1634,26 @@ convertLegendValue(v::AbstractArray) = map(convertLegendValue, v) # ----------------------------------------------------------------------------- +"""Throw an error if the `levels` keyword argument is not of the correct type +or `levels` is less than 1""" +function check_contour_levels(levels) + if !(levels isa Union{Integer,AVec}) + throw( + ArgumentError( + "the levels keyword argument must be an integer or AbstractVector", + ), + ) + elseif levels isa Integer && levels <= 0 + throw( + ArgumentError( + "must pass a positive number of contours to the levels keyword argument", + ), + ) + end +end + +# ----------------------------------------------------------------------------- + # 1-row matrices will give an element # multi-row matrices will give a column # InputWrapper just gives the contents diff --git a/src/backends/plotly.jl b/src/backends/plotly.jl index 8a47fc1b..adc9baa5 100644 --- a/src/backends/plotly.jl +++ b/src/backends/plotly.jl @@ -617,11 +617,34 @@ function plotly_series(plt::Plot, series::Series) filled = isfilledcontour(series) plotattributes_out[:type] = "contour" plotattributes_out[:x], plotattributes_out[:y], plotattributes_out[:z] = x, y, z - plotattributes_out[:ncontours] = series[:levels] + 2 plotattributes_out[:contours] = KW( :coloring => filled ? "fill" : "lines", :showlabels => series[:contour_labels] == true, ) + # Plotly does not support arbitrary sets of contours + # (https://github.com/plotly/plotly.js/issues/4503) + # so we distinguish AbstractRanges and AbstractVectors + let levels = series[:levels] + if levels isa AbstractRange + plotattributes_out[:contours][:start] = first(levels) + plotattributes_out[:contours][:end] = last(levels) + plotattributes_out[:contours][:size] = step(levels) + elseif levels isa AVec + levels_range = + range(first(levels), stop = last(levels), length = length(levels)) + plotattributes_out[:contours][:start] = first(levels_range) + plotattributes_out[:contours][:end] = last(levels_range) + plotattributes_out[:contours][:size] = step(levels_range) + @warn( + "setting arbitrary contour levels with Plotly backend is not supported; " * + "use a range to set equally-spaced contours or an integer to set the " * + "approximate number of contours with the keyword `levels`. " * + "Setting levels to $(levels_range)" + ) + elseif levels isa Integer + plotattributes_out[:ncontours] = levels + 2 + end + end plotattributes_out[:colorscale] = plotly_colorscale(series[:linecolor], series[:linealpha]) plotattributes_out[:showscale] = hascolorbar(sp) && hascolorbar(series) diff --git a/src/backends/pyplot.jl b/src/backends/pyplot.jl index 2ef25820..d0d237d3 100644 --- a/src/backends/pyplot.jl +++ b/src/backends/pyplot.jl @@ -390,8 +390,6 @@ function py_add_series(plt::Plot{PyPlotBackend}, series::Series) elseif isvector(levels) extrakw[:levels] = levels () - else - error("Only numbers and vectors are supported with levels keyword") end # add custom frame shapes to markershape? diff --git a/test/runtests.jl b/test/runtests.jl index 24931870..7e4150ff 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -31,6 +31,7 @@ end # testset include("test_defaults.jl") include("test_pipeline.jl") include("test_axes.jl") +include("test_contours.jl") include("test_axis_letter.jl") include("test_components.jl") include("test_shorthands.jl") @@ -38,6 +39,7 @@ include("integration_dates.jl") include("test_recipes.jl") include("test_hdf5plots.jl") include("test_pgfplotsx.jl") +include("test_plotly.jl") reference_dir(args...) = joinpath(homedir(), ".julia", "dev", "PlotReferenceImages", args...) diff --git a/test/test_contours.jl b/test/test_contours.jl new file mode 100644 index 00000000..82e42bec --- /dev/null +++ b/test/test_contours.jl @@ -0,0 +1,68 @@ +using Plots, Test +import RecipesPipeline + +@testset "Contours" begin + @testset "check_contour_levels" begin + @test Plots.check_contour_levels(2) === nothing + @test Plots.check_contour_levels(-1.0:0.2:10.0) === nothing + @test Plots.check_contour_levels([-100, -2, -1, 0, 1, 2, 100]) === nothing + @test_throws ArgumentError Plots.check_contour_levels(1.0) + @test_throws ArgumentError Plots.check_contour_levels((1, 2, 3)) + @test_throws ArgumentError Plots.check_contour_levels(-3) + end + + @testset "RecipesPipeline.preprocess_attributes!" begin + function equal_after_pipeline(kw) + kw′ = deepcopy(kw) + RecipesPipeline.preprocess_attributes!(kw′) + kw == kw′ + end + + @test equal_after_pipeline(KW(:levels => 1)) + @test equal_after_pipeline(KW(:levels => 1:10)) + @test equal_after_pipeline(KW(:levels => [1.0, 3.0, 5.0])) + @test_throws ArgumentError RecipesPipeline.preprocess_attributes!( + KW(:levels => 1.0), + ) + @test_throws ArgumentError RecipesPipeline.preprocess_attributes!( + KW(:levels => (1, 2, 3)), + ) + @test_throws ArgumentError RecipesPipeline.preprocess_attributes!(KW(:levels => -3)) + end + + @testset "contour[f]" begin + x = (-2π):0.1:(2π) + y = (-π):0.1:π + z = cos.(y) .* sin.(x') + + @testset "Incorrect input" begin + @test_throws ArgumentError contour(x, y, z, levels = 1.0) + @test_throws ArgumentError contour(x, y, z, levels = (1, 2, 3)) + @test_throws ArgumentError contour(x, y, z, levels = -3) + end + + @testset "Default number" begin + @test contour(x, y, z)[1][1].plotattributes[:levels] == + Plots._series_defaults[:levels] + end + + @testset "Number" begin + @testset "$n contours" for n in (2, 5, 100) + p = contour(x, y, z, levels = n) + attr = p[1][1].plotattributes + @test attr[:seriestype] == :contour + @test attr[:levels] == n + end + end + + @testset "Range" begin + levels = -1:0.5:1 + @test contour(x, y, z, levels = levels)[1][1].plotattributes[:levels] == levels + end + + @testset "Set of levels" begin + levels = [-1, 0.25, 0, 0.25, 1] + @test contour(x, y, z, levels = levels)[1][1].plotattributes[:levels] == levels + end + end +end diff --git a/test/test_plotly.jl b/test/test_plotly.jl new file mode 100644 index 00000000..0758c410 --- /dev/null +++ b/test/test_plotly.jl @@ -0,0 +1,57 @@ +using Plots, Test + +@testset "Plotly" begin + @testset "Basic" begin + @test plotly() == Plots.PlotlyBackend() + @test backend() == Plots.PlotlyBackend() + + p = plot(rand(10)) + @test isa(p, Plots.Plot) == true + end + + @testset "Contours" begin + x = (-2π):0.1:(2π) + y = (-π):0.1:π + z = cos.(y) .* sin.(x') + + @testset "Contour numbers" begin + @testset "Default" begin + @test Plots.plotly_series(contour(x, y, z))[1][:ncontours] == + Plots._series_defaults[:levels] + 2 + end + @testset "Specified number" begin + @test Plots.plotly_series(contour(x, y, z, levels = 10))[1][:ncontours] == + 12 + end + end + + @testset "Contour values" begin + @testset "Range" begin + levels = -1:0.5:1 + p = contour(x, y, z, levels = levels) + @test p[1][1].plotattributes[:levels] == levels + @test Plots.plotly_series(p)[1][:contours][:start] == first(levels) + @test Plots.plotly_series(p)[1][:contours][:end] == last(levels) + @test Plots.plotly_series(p)[1][:contours][:size] == step(levels) + end + + @testset "Set of contours" begin + levels = [-1, -0.25, 0, 0.25, 1] + levels_range = + range(first(levels), stop = last(levels), length = length(levels)) + p = contour(x, y, z, levels = levels) + @test p[1][1].plotattributes[:levels] == levels + series_dict = @test_logs ( + :warn, + "setting arbitrary contour levels with Plotly backend " * + "is not supported; use a range to set equally-spaced contours or an " * + "integer to set the approximate number of contours with the keyword " * + "`levels`. Setting levels to -1.0:0.5:1.0", + ) Plots.plotly_series(p) + @test series_dict[1][:contours][:start] == first(levels_range) + @test series_dict[1][:contours][:end] == last(levels_range) + @test series_dict[1][:contours][:size] == step(levels_range) + end + end + end +end