diff --git a/GLMakie/src/drawing_primitives.jl b/GLMakie/src/drawing_primitives.jl index 6097de30483..540e1e7d62d 100644 --- a/GLMakie/src/drawing_primitives.jl +++ b/GLMakie/src/drawing_primitives.jl @@ -42,33 +42,31 @@ function connect_camera!(plot, gl_attributes, cam, space = gl_attributes[:space] gl_attributes[key] = lift(identity, plot, getfield(cam, key)) end get!(gl_attributes, :view) do - get!(cam.calculated_values, :view) do + # get!(cam.calculated_values, Symbol("view_$(space[])")) do return lift(plot, cam.view, space) do view, space return is_data_space(space) ? view : Mat4f(I) end - end + # end end get!(gl_attributes, :normalmatrix) do - get!(cam.calculated_values, :normalmatrix) do - return lift(plot, gl_attributes[:view], gl_attributes[:model]) do v, m - i = Vec(1, 2, 3) - return transpose(inv(v[i, i] * m[i, i])) - end + return lift(plot, gl_attributes[:view], gl_attributes[:model]) do v, m + i = Vec(1, 2, 3) + return transpose(inv(v[i, i] * m[i, i])) end end get!(gl_attributes, :projection) do - get!(cam.calculated_values, :projection) do + # return get!(cam.calculated_values, Symbol("projection_$(space[])")) do return lift(cam.projection, cam.pixel_space, space) do _, _, space return Makie.space_to_clip(cam, space, false) end - end + # end end get!(gl_attributes, :projectionview) do - get!(cam.calculated_values, :projectionview) do + # get!(cam.calculated_values, Symbol("projectionview_$(space[])")) do return lift(plot, cam.projectionview, cam.pixel_space, space) do _, _, space Makie.space_to_clip(cam, space, true) end - end + # end end # resolution in real hardware pixels, not scaled pixels/units get!(gl_attributes, :resolution) do @@ -76,7 +74,7 @@ function connect_camera!(plot, gl_attributes, cam, space = gl_attributes[:space] return lift(*, plot, gl_attributes[:px_per_unit], cam.resolution) end end - + delete!(gl_attributes, :space) delete!(gl_attributes, :markerspace) return nothing @@ -156,6 +154,8 @@ function cached_robj!(robj_func, screen, scene, x::AbstractPlot) return robj end +Base.insert!(::GLMakie.Screen, ::Scene, ::Makie.PlotList) = nothing + function Base.insert!(screen::Screen, scene::Scene, x::Combined) ShaderAbstractions.switch_context!(screen.glscreen) # poll inside functions to make wait on compile less prominent diff --git a/MakieCore/src/recipes.jl b/MakieCore/src/recipes.jl index 19d6d55ff4f..2f3f564cfb9 100644 --- a/MakieCore/src/recipes.jl +++ b/MakieCore/src/recipes.jl @@ -31,28 +31,28 @@ function create_figurelike! end function figurelike_return end function figurelike_return! end -function _create_plot(F, attributes, args...) +function _create_plot(F, attributes::Dict, args...) figlike, plot_kw, plot_args = create_figurelike(Combined{F}, attributes, args...) plot = Combined{F}(plot_args, plot_kw) plot!(figlike, plot) return figurelike_return(figlike, plot) end -function _create_plot!(F, attributes, args...) +function _create_plot!(F, attributes::Dict, args...) figlike, plot_kw, plot_args = create_figurelike!(Combined{F}, attributes, args...) plot = Combined{F}(plot_args, plot_kw) plot!(figlike, plot) return figurelike_return!(figlike, plot) end -function _create_plot!(F, kw, scene::SceneLike, args...) +function _create_plot!(F, kw::Dict, scene::SceneLike, args...) plot = Combined{F}(args, kw) plot!(scene, plot) return plot end -plot(args...; kw...) = _create_plot(plot, Dict(kw), args...) -plot!(args...; kw...) = _create_plot!(plot, Dict(kw), args...) +plot(args...; kw...) = _create_plot(plot, Dict{Symbol, Any}(kw), args...) +plot!(args...; kw...) = _create_plot!(plot, Dict{Symbol, Any}(kw), args...) """ diff --git a/MakieCore/src/types.jl b/MakieCore/src/types.jl index 17fe6e86f5a..a3c8c711aba 100644 --- a/MakieCore/src/types.jl +++ b/MakieCore/src/types.jl @@ -65,7 +65,7 @@ mutable struct Combined{Typ, T} <: ScenePlot{Typ} deregister_callbacks::Vector{Observables.ObserverFunction} parent::Union{AbstractScene,Combined} - function Combined{Typ,T}(transformation, kw, args) where {Typ,T} + function Combined{Typ,T}(transformation, kw::Dict{Symbol, Any}, args::Vector{Any}) where {Typ,T} return new{Typ,T}(transformation, kw, args, (), Attributes(), Combined[], Observables.ObserverFunction[]) end diff --git a/WGLMakie/src/serialization.jl b/WGLMakie/src/serialization.jl index 2be82c896a4..c731af1519a 100644 --- a/WGLMakie/src/serialization.jl +++ b/WGLMakie/src/serialization.jl @@ -306,6 +306,7 @@ end function serialize_plots(scene::Scene, plots::Vector{T}, result=[]) where {T<:AbstractPlot} for plot in plots + plot isa Makie.PlotList && continue # if no plots inserted, this truely is an atomic if isempty(plot.plots) plot_data = serialize_three(scene, plot) diff --git a/src/Makie.jl b/src/Makie.jl index b45ac81d378..78a9987bf47 100644 --- a/src/Makie.jl +++ b/src/Makie.jl @@ -131,6 +131,7 @@ include("camera/camera3d.jl") include("camera/old_camera3d.jl") # basic recipes +include("basic_recipes/plotspec.jl") include("basic_recipes/convenience_functions.jl") include("basic_recipes/ablines.jl") include("basic_recipes/annotations.jl") diff --git a/src/basic_recipes/plotspec.jl b/src/basic_recipes/plotspec.jl new file mode 100644 index 00000000000..de5034a12bb --- /dev/null +++ b/src/basic_recipes/plotspec.jl @@ -0,0 +1,220 @@ +# Ideally we re-use Makie.PlotSpec, but right now we need a bit of special behaviour to make this work nicely. +# If the implementation stabilizes, we should think about refactoring PlotSpec to work for both use cases, and then just have one PlotSpec type. +@nospecialize + +""" + PlotSpec{P<:AbstractPlot}(args...; kwargs...) + +Object encoding positional arguments (`args`), a `NamedTuple` of attributes (`kwargs`) +as well as plot type `P` of a basic plot. +""" +struct PlotSpec{P<:AbstractPlot} + args::Vector{Any} + kwargs::Dict{Symbol, Any} + function PlotSpec{P}(args...; kwargs...) where {P<:AbstractPlot} + kw = Dict{Symbol,Any}() + for (k, v) in kwargs + # convert eagerly, so that we have stable types for matching later + # E.g. so that PlotSpec(; color = :red) has the same type as PlotSpec(; color = RGBA(1, 0, 0, 1)) + kw[k] = convert_attribute(v, Key{k}(), Key{plotkey(P)}()) + end + return new{P}(Any[args...], kw) + end + PlotSpec(args...; kwargs...) = new{Combined{plot}}(args...; kwargs...) +end +@specialize + +Base.getindex(p::PlotSpec, i::Int) = getindex(p.args, i) +Base.getindex(p::PlotSpec, i::Symbol) = getproperty(p.kwargs, i) + +to_plotspec(::Type{P}, args; kwargs...) where {P} = PlotSpec{P}(args...; kwargs...) + +function to_plotspec(::Type{P}, p::PlotSpec{S}; kwargs...) where {P,S} + return PlotSpec{plottype(P, S)}(p.args...; p.kwargs..., kwargs...) +end + +plottype(::PlotSpec{P}) where {P} = P + +""" +apply for return type PlotSpec +""" +function apply_convert!(P, attributes::Attributes, x::PlotSpec{S}) where {S} + args, kwargs = x.args, x.kwargs + # Note that kw_args in the plot spec that are not part of the target plot type + # will end in the "global plot" kw_args (rest) + for (k, v) in pairs(kwargs) + attributes[k] = v + end + return (plottype(S, P), (args...,)) +end + +function apply_convert!(P, ::Attributes, x::AbstractVector{<:PlotSpec}) + return (PlotList, (x,)) +end + +function MakieCore.argtypes(plot::PlotSpec{P}) where {P} + args_converted = convert_arguments(P, plot.args...) + return MakieCore.argtypes(args_converted) +end + +struct SpecApi end +function Base.getproperty(::SpecApi, field::Symbol) + P = Combined{getfield(Makie, field)} + return PlotSpec{P} +end + +const PlotspecApi = SpecApi() + +# comparison based entirely of types inside args + kwargs +compare_specs(a::PlotSpec{A}, b::PlotSpec{B}) where {A, B} = false + +function compare_specs(a::PlotSpec{T}, b::PlotSpec{T}) where {T} + length(a.args) == length(b.args) || return false + all(i-> typeof(a.args[i]) == typeof(b.args[i]), 1:length(a.args)) || return false + + length(a.kwargs) == length(b.kwargs) || return false + ka = keys(a.kwargs) + kb = keys(b.kwargs) + ka == kb || return false + all(k -> typeof(a.kwargs[k]) == typeof(b.kwargs[k]), ka) || return false + return true +end + +function update_plot!(plot::AbstractPlot, spec::PlotSpec) + # Update args in plot `input_args` list + for i in eachindex(spec.args) + # we should only call update_plot!, if compare_spec(spec_plot_got_created_from, spec) == true, + # Which should guarantee, that args + kwargs have the same length and types! + arg_obs = plot.args[i] + if to_value(arg_obs) != spec.args[i] # only update if different + @debug("updating arg $i") + arg_obs[] = spec.args[i] + end + end + # Update attributes + for (attribute, new_value) in spec.kwargs + if plot[attribute][] != new_value # only update if different + @debug("updating kw $attribute") + plot[attribute] = new_value + end + end +end + +""" + plotlist!( + [ + PlotSpec{SomePlotType}(args...; kwargs...), + PlotSpec{SomeOtherPlotType}(args...; kwargs...), + ] + ) + +Plots a list of PlotSpec's, which can be an observable, making it possible to create efficiently animated plots with the following API: + +## Example +```julia +using GLMakie +import Makie.PlotspecApi as P + +fig = Figure() +ax = Axis(fig[1, 1]) +plots = Observable([P.heatmap(0 .. 1, 0 .. 1, Makie.peaks()), P.lines(0 .. 1, sin.(0:0.01:1); color=:blue)]) +pl = plot!(ax, plots) +display(fig) + +# Updating the plot dynamically +plots[] = [P.heatmap(0 .. 1, 0 .. 1, Makie.peaks()), P.lines(0 .. 1, sin.(0:0.01:1); color=:red)] +plots[] = [ + P.image(0 .. 1, 0 .. 1, Makie.peaks()), + P.poly(Rect2f(0.45, 0.45, 0.1, 0.1)), + P.lines(0 .. 1, sin.(0:0.01:1); linewidth=10, color=Makie.resample_cmap(:viridis, 101)), +] + +plots[] = [ + P.surface(0..1, 0..1, Makie.peaks(); colormap = :viridis, translation = Vec3f(0, 0, -1)), +] +``` +""" +@recipe(PlotList, plotspecs) do scene + Attributes() +end + +convert_arguments(::Type{<:AbstractPlot}, args::AbstractArray{<:PlotSpec}) = (args,) +plottype(::AbstractVector{<:PlotSpec}) = PlotList + +# Since we directly plot into the parent scene (hacky), we need to overload these +Base.insert!(::MakieScreen, ::Scene, ::PlotList) = nothing + +# TODO, make this work with Cycling and also with convert_arguments returning +# Vector{PlotSpec} so that one can write recipes like this: +quote + Makie.convert_arguments(obj::MyType) = [ + obj.lineplot ? P.lines(obj.args...; obj.kwargs...) : P.scatter(obj.args...; obj.kw...) + ] +end + +function Base.show(io::IO, ::MIME"text/plain", spec::PlotSpec{P}) where {P} + args = join(map(x -> string("::", typeof(x)), spec.args), ", ") + kws = join([string(k, " = ", typeof(v)) for (k, v) in spec.kwargs], ", ") + println(io, "P.", plotfunc(P), "($args; $kws)") +end + +function Base.show(io::IO, spec::PlotSpec{P}) where {P} + args = join(map(x -> string("::", typeof(x)), spec.args), ", ") + kws = join([string(k, " = ", typeof(v)) for (k, v) in spec.kwargs], ", ") + return println(io, "P.", plotfunc(P), "($args; $kws)") +end + +function to_combined(ps::PlotSpec{P}) where {P} + return P((ps.args...,), copy(ps.kwargs)) +end + +function Makie.plot!(p::PlotList{<: Tuple{<: AbstractArray{<: PlotSpec}}}) + # Cache plots here so that we aren't re-creating plots every time; + # if a plot still exists from last time, update it accordingly. + # If the plot is removed from `plotspecs`, we'll delete it from here + # and re-create it if it ever returns. + cached_plots = Pair{PlotSpec, Combined}[] + scene = Makie.parent_scene(p) + on(p.plotspecs; update=true) do plotspecs + used_plots = Set{Int}() + for plotspec in plotspecs + # we need to compare by types with compare_specs, since we can only update plots if the types of all attributes match + idx = findfirst(x-> compare_specs(x[1], plotspec), cached_plots) + if isnothing(idx) + @debug("Creating new plot for spec") + # Create new plot, store it into our `cached_plots` dictionary + plot = plot!(scene, to_combined(plotspec)) + push!(p.plots, plot) + push!(cached_plots, plotspec => plot) + push!(used_plots, length(cached_plots)) + else + @debug("updating old plot with spec") + push!(used_plots, idx) + plot = cached_plots[idx][2] + update_plot!(plot, plotspec) + cached_plots[idx] = plotspec => plot + end + end + unused_plots = setdiff(1:length(cached_plots), used_plots) + # Next, delete all plots that we haven't used + # TODO, we could just hide them, until we reach some max_plots_to_be_cached, so that we re-create less plots. + for idx in unused_plots + _, plot = cached_plots[idx] + delete!(scene, plot) + filter!(x -> x !== plot, p.plots) + end + splice!(cached_plots, sort!(collect(unused_plots))) + end +end + +# Prototype for Pluto + Ijulia integration with Observable(ListOfPlots) +function Base.showable(::Union{MIME"juliavscode/html",MIME"text/html"}, ::Observable{<: AbstractVector{<:PlotSpec}}) + return true +end + +function Base.show(io::IO, m::Union{MIME"juliavscode/html",MIME"text/html"}, + plotspec::Observable{<:AbstractVector{<:PlotSpec}}) + f = plot(plotspec) + show(io, m, f) + return +end diff --git a/src/figureplotting.jl b/src/figureplotting.jl index 2974939d899..88d58cf3b52 100644 --- a/src/figureplotting.jl +++ b/src/figureplotting.jl @@ -90,8 +90,8 @@ function preferred_axis_type(@nospecialize(p::PlotFunc), @nospecialize(args...)) pre_conversion_result = args_preferred_axis(RealP, non_obs...) isnothing(pre_conversion_result) || return pre_conversion_result conv = convert_arguments(RealP, non_obs...) - args_conv = apply_convert!(Attributes(), conv) - result = args_preferred_axis(RealP, args_conv...) + FinalP, args_conv = apply_convert!(RealP, Attributes(), conv) + result = args_preferred_axis(FinalP, args_conv...) isnothing(result) && return Axis # Fallback to Axis if nothing found return result end diff --git a/src/interfaces.jl b/src/interfaces.jl index 4fed6819c45..ae2920ae8fd 100644 --- a/src/interfaces.jl +++ b/src/interfaces.jl @@ -93,19 +93,18 @@ function calculated_attributes!(::Type{T}, plot) where {T<:Union{Lines, LineSegm return end -const atomic_function_symbols = ( - :text, :meshscatter, :scatter, :mesh, :linesegments, - :lines, :surface, :volume, :heatmap, :image +const atomic_functions = ( + text, meshscatter, scatter, mesh, linesegments, + lines, surface, volume, heatmap, image ) - -const atomic_functions = getfield.(Ref(Makie), atomic_function_symbols) const Atomic{Arg} = Union{map(x-> Combined{x, Arg}, atomic_functions)...} function convert_arguments!(plot::Combined{F}) where {F} P = Combined{F,Any} function on_update(kw, args...) nt = convert_arguments(P, args...; kw...) - converted = apply_convert!(plot.attributes, nt) + pnew, converted = apply_convert!(P, plot.attributes, nt) + @assert plotfunc(pnew) === F "Changed the plot type in convert_arguments. This isn't allowed!" for (obs, new_val) in zip(plot.converted, converted) obs[] = new_val end @@ -130,8 +129,10 @@ function Combined{Func}(args::Tuple, plot_attributes::Dict) where {Func} return Combined{Func}(Base.tail(args), plot_attributes) end P = Combined{Func} - args_converted = convert_arguments(P, map(to_value, args)...) - converted = apply_convert!(Attributes(), args_converted) + used_attrs = used_attributes(P, to_value.(args)...) + kw = [Pair(k, to_value(v)) for (k, v) in plot_attributes if k in used_attrs] + args_converted = convert_arguments(P, map(to_value, args)...; kw...) + PNew, converted = apply_convert!(P, Attributes(), args_converted) trans = get!(plot_attributes, :transformation, automatic) transval = to_value(trans) transformation = if transval isa Automatic @@ -147,7 +148,7 @@ function Combined{Func}(args::Tuple, plot_attributes::Dict) where {Func} obs_args = Any[convert(Observable, x) for x in args] ArgTyp = MakieCore.argtypes(converted) - plot = Combined{Func, ArgTyp}(transformation, plot_attributes, obs_args) + plot = Combined{plotfunc(PNew), ArgTyp}(transformation, plot_attributes, obs_args) plot.converted = map(Observable, converted) plot.model = transformationmatrix(transformation) return plot @@ -181,20 +182,7 @@ used_attributes(PlotType, args...) = () apply for return type (args...,) """ -apply_convert!(attributes::Attributes, x::Tuple) = x - -""" -apply for return type PlotSpec -""" -function apply_convert!(attributes::Attributes, x::PlotSpec{S}) where S - args, kwargs = x.args, x.kwargs - # Note that kw_args in the plot spec that are not part of the target plot type - # will end in the "global plot" kw_args (rest) - for (k, v) in pairs(kwargs) - attributes[k] = v - end - return args -end +apply_convert!(P, ::Attributes, x::Tuple) = (P, x) function seperate_tuple(args::Observable{<: NTuple{N, Any}}) where N ntuple(N) do i @@ -208,19 +196,6 @@ function seperate_tuple(args::Observable{<: NTuple{N, Any}}) where N end end -function plot(scene::Scene, plot::AbstractPlot) - # plot object contains local theme (default values), and user given values (from constructor) - # fill_theme now goes through all values that are missing from the user, and looks if the scene - # contains any theming values for them (via e.gg. css rules). If nothing founds, the values will - # be taken from local theme! This will connect any values in the scene's theme - # with the plot values and track those connection, so that we can separate them - # when doing delete!(scene, plot)! - complete_theme!(scene, plot) - # we just return the plot... whoever calls plot (our pipeline usually) - # will need to push!(scene, plot) etc! - return plot -end - ## generic definitions # If the Combined has no plot func, calculate them plottype(::Type{<: Combined{Any}}, argvalues...) = plottype(argvalues...) @@ -262,15 +237,32 @@ plottype(P1::Type{<: Combined{T}}, P2::Type{<: Combined}) where T = P1 # all the plotting functions that get a plot type const PlotFunc = Union{Type{Any},Type{<:AbstractPlot}} - -function plot!(plot::Combined{F}) where {F} +function plot!(::Combined{F}) where {F} if !(F in atomic_functions) error("No recipe for $(F)") end end +function connect_plot!(scene::SceneLike, plot::Combined{F}) where {F} + plot.parent = scene + # TODO, move transformation into attributes? + # This hacks around transformation being already constructed in the constructor + # So here we don't want to connect to the scene if an explicit Transformation was passed to the plot + kw = getfield(plot, :kw) + attr = getfield(plot, :attributes) + t = to_value(get(() -> get(kw, :transformation, nothing), attr, :transformation)) + if t isa Automatic + connect!(transformation(scene), transformation(plot)) + end + apply_theme!(parent_scene(scene), plot) + convert_arguments!(plot) + calculated_attributes!(Combined{F}, plot) + plot!(plot) + return plot +end + function plot!(scene::SceneLike, plot::Combined) - prepare_plot!(scene, plot) + connect_plot!(scene, plot) push!(scene, plot) return plot end @@ -292,26 +284,3 @@ function apply_theme!(scene::Scene, plot::P) where {P<: Combined} end return merge!(plot.attributes, plot_theme) end - -function prepare_plot!(scene::SceneLike, plot::Combined{F}) where {F} - plot.parent = scene - # TODO, move transformation into attributes? - # This hacks around transformation being already constructed in the constructor - # So here we don't want to connect to the scene if an explicit Transformation was passed to the plot - kw = getfield(plot, :kw) - attr = getfield(plot, :attributes) - t = to_value(get(() -> get(kw, :transformation, nothing), attr, :transformation)) - if t isa Automatic - connect!(transformation(scene), transformation(plot)) - end - apply_theme!(parent_scene(scene), plot) - convert_arguments!(plot) - calculated_attributes!(Combined{F}, plot) - plot!(plot) - return plot -end - -function MakieCore.argtypes(plot::PlotSpec{P}) where {P} - args_converted = convert_arguments(P, plot.args...) - return MakieCore.argtypes(args_converted) -end diff --git a/src/scenes.jl b/src/scenes.jl index 6b0c2ba25e2..5c17e6e906f 100644 --- a/src/scenes.jl +++ b/src/scenes.jl @@ -473,7 +473,6 @@ end function Base.push!(scene::Scene, plot::AbstractPlot) push!(scene.plots, plot) - plot isa Combined || (plot.parent = scene) for screen in scene.current_screens insert!(screen, scene, plot) end @@ -511,21 +510,6 @@ function Base.delete!(scene::Scene, plot::AbstractPlot) free(plot) end -function Base.push!(scene::Scene, child::Scene) - push!(scene.children, child) - disconnect!(child.camera) - observables = map([:view, :projection, :projectionview, :resolution, :eyeposition]) do field - return lift(getfield(scene.camera, field)) do val - getfield(child.camera, field)[] = val - getfield(child.camera, field)[] = val - return - end - end - cameracontrols!(child, observables) - child.parent = scene - return scene -end - events(x) = events(get_scene(x)) events(scene::Scene) = scene.events events(scene::SceneLike) = events(scene.parent) diff --git a/src/stats/hist.jl b/src/stats/hist.jl index bf5cd7e5bf8..0ea0ea910b7 100644 --- a/src/stats/hist.jl +++ b/src/stats/hist.jl @@ -1,10 +1,10 @@ -const histogram_plot_types = [BarPlot, Heatmap, Volume] +const histogram_plot_types = (BarPlot, Heatmap, Volume) function convert_arguments(P::Type{<:AbstractPlot}, h::StatsBase.Histogram{<:Any, N}) where N ptype = plottype(P, histogram_plot_types[N]) f(edges) = edges[1:end-1] .+ diff(edges)./2 kwargs = N == 1 ? (; width = step(h.edges[1]), gap = 0, dodge_gap = 0) : NamedTuple() - to_plotspec(ptype, convert_arguments(ptype, map(f, h.edges)..., Float64.(h.weights)); kwargs...) + return to_plotspec(ptype, convert_arguments(ptype, map(f, h.edges)..., Float64.(h.weights)); kwargs...) end function _hist_center_weights(values, edges, normalization, scale_to, wgts) diff --git a/src/types.jl b/src/types.jl index 0be83f455f5..601d8ef9c5a 100644 --- a/src/types.jl +++ b/src/types.jl @@ -306,32 +306,6 @@ function Transformation(parent::Transformable; return trans end -""" -`PlotSpec{P<:AbstractPlot}(args...; kwargs...)` - -Object encoding positional arguments (`args`), a `NamedTuple` of attributes (`kwargs`) -as well as plot type `P` of a basic plot. -""" -struct PlotSpec{P<:AbstractPlot} - args::Tuple - kwargs::NamedTuple - PlotSpec{P}(args...; kwargs...) where {P<:AbstractPlot} = new{P}(args, values(kwargs)) -end - -PlotSpec(args...; kwargs...) = PlotSpec{Combined{Any}}(args...; kwargs...) - -Base.getindex(p::PlotSpec, i::Int) = getindex(p.args, i) -Base.getindex(p::PlotSpec, i::Symbol) = getproperty(p.kwargs, i) - -to_plotspec(::Type{P}, args; kwargs...) where {P} = - PlotSpec{P}(args...; kwargs...) - -to_plotspec(::Type{P}, p::PlotSpec{S}; kwargs...) where {P, S} = - PlotSpec{plottype(P, S)}(p.args...; p.kwargs..., kwargs...) - -plottype(::PlotSpec{P}) where {P} = P - - struct ScalarOrVector{T} sv::Union{T, Vector{T}} end