Makie.jl icon indicating copy to clipboard operation
Makie.jl copied to clipboard

Introduce AxisSpace type to generalize and solve multiple problems

Open rapus95 opened this issue 3 years ago • 9 comments

Over time this proposal evolved a bit further. There you find the current state: https://github.com/JuliaPlots/Makie.jl/issues/1731#issuecomment-1199785866


Outdated

Proposal: Introduce an AxisSpace type

First draft (=everything in this issue is an idea to evolve into a perfect solution for as many problems as possible):

abstract type AxisSpace{T} end
struct DomainSpace{T} <: AxisSpace{T} ... end
struct TargetSpace{T} <: AxisSpace{T} ... end

Alternative names: AxisSpace: AxisDimension TargetSpace: ImageSpace

Direction keyword (https://github.com/JuliaPlots/Makie.jl/issues/926)

Solves the problem by getting rid of the keyword entirely and encoding the plot direction in the relative position of DomainSpace and TargetSpace objects:

barplot(verticalspace=domainspace, horizontalspace=targetspace)

This would plot to the right because the domainspace is on the vertical axis. Switching the two arguments will plot to the top.

Bonus:

  • decouples axis directions from x/y/z
  • allows for getting rid of the x/y/z variants of all the decoration keywords by moving them into the AxisSpace type
  • thus solves x/y variants interfering with the direction keyword

Axis decoration rendering (https://github.com/JuliaPlots/Makie.jl/pull/1347)

easily extensible interface for plotting different axis value types independently of x, y etc:

labels(s::AxisSpace{<:DateTime}, args...) = transformdatatolabels(s, args...)

Axis linking

This also allows for a simple way of linking multiple axes by simply supplying the same space to multiple plots! (And in a further future we could even derive layout optimizations like omitting some axis labels if neighboring plots share the same AxisSpace for the same axis)

higher dimensional plotting

We can encode data as a combination of any number of DomainSpace and TargetSpace objects selectively plotted across multiple plots. 3D plots would just be 3 AxisSpace objects passed together. If it's 2 DomainSpace it most porbably will be some surface type of plotting (we could even use this for guidance/error checking regarding correct use of plot types) and if it's 2 TargetSpace it refers to some curve-like plotting.

Compatibility

Since we introduce a new type we can easily introduce it into the syntax and just redirect the current syntax into proper AxisSpace calls. At some future point we can deprecate some of the current syntax and on a major version increase drop it. But until then it just offers a way more versatile way of plotting! Tl;Dr: As the proposal is strictly more powerful, we can just embed the current syntax into it and thus have maximum compatibility.

Opinion

I like this idea especially because it brings us closer to the mathematical concept of plotting. And given Julia's capability to recreate mathematical generalizations, I assume it's profitable to stay close to mathematics.

rapus95 avatar Mar 08 '22 11:03 rapus95

@jkrumbiegel @piever someone told me it'd be best to ping the two of you here! I'd be happy to hear your thoughts on this abstract concept idea 😄

rapus95 avatar May 16 '22 09:05 rapus95

I also think it would be nice to get rid of the orientation keyword, if possible.

In my mind, it seems like a possible solution would be to encode the orientation directly in the Axis. Basically, the axis would have an orientation attribute. To make a plot with a different orientation, one would plot onto a "flipped axis". The intuition is that this way one doesn't need to add orientation attributes to all the recipes. This also enforces that all plots on an axis are flipped (or none is).

OTOH, I confess I haven't a clear idea as to what xlabel, linkxaxes, etc. would refer to in that scenario. A consistent approach would be to decide that the x axis is the vertical one and the y axis the horizontal one when the Axis is flipped.

piever avatar Jun 12 '22 10:06 piever

@piever an even better approach would be to stop using x/y/z at all. I'd instead make them just positional arguments and encode the layout within the types and order of them (as proposed). What do you think about all the other benefits I came up with by generalizing it into an AxisSpace type?

Generally speaking, having x/y/z variants is a strong hint towards lacking abstraction.

rapus95 avatar Jun 13 '22 10:06 rapus95

To flip the axis is suprisingly simple (but does need some patching):

using Makie, CairoMakie

# patch a bug in Makie, will push a PR laters
@eval Makie begin

       function apply_transform(f::PointTrans{N}, point::VecTypes{N}) where N
           return f.f(point)
       end

       function apply_transform(f::PointTrans{N1}, point::VecTypes{N2}) where {N1, N2}
           p_dim = to_ndim(Point{N1, Float32}, point, 0.0)
           p_trans = f.f(p_dim)
           if N1 < N2
               p_large = ntuple(i-> i <= N1 ? p_trans[i] : point[i], N2)
               return Point{N2, Float32}(p_large)
           else
               return to_ndim(Point{N2, Float32}, p_trans, 0.0)
           end
       end
end

Then we can define a transformation which just reverses coordinate order:


trans = Makie.PointTrans{2}(reverse)

Makie.apply_transform(::typeof(trans), r::Rect2) = Rect2(reverse(r.origin), reverse(r.widths))

Finally we plot:



tbl = (x = [1, 1, 1, 2, 2, 2, 3, 3, 3],
       height = 0.1:0.1:0.9,
       grp = [1, 2, 3, 1, 2, 3, 1, 2, 3],
       grp1 = [1, 2, 2, 1, 1, 2, 1, 1, 2],
       grp2 = [1, 1, 2, 1, 2, 1, 1, 2, 1]
       )

fig, ax, plt = barplot(tbl.x, tbl.height,
       stack = tbl.grp,
       color = tbl.grp,
       orientation = :x)

st = Stepper(fig)
ax.title[] = "Step 1"

Makie.step!(st)

ax.scene.transformation.transform_func[] = trans
ax.title[] = "Step 2"

Makie.step!(st)

save("axis_transformation", st)


step-1 step-2

Note that the ticks are off here. Adding direction=:x to step1 leads to: original

so we only need to make the standard Axis aware of this, so that it can change where its limits and labels go. The x ticks on step2 correspond to the y ticks above, and vice versa.

We could theoretically implement this using a coord_order = [1, 2] | [2, 1] keyword argument, which would also make this extensible to 3d. The only issue with the barplots now is label positioning, since the aligns seem to differ based on orientation. The recipe could be made aware of this by accessing the plot's parent scene but that's not great for portability.

@piever: I agree with your idea that "x" should always refer to horizontal, and "y" to vertical.

@rapus95: I'm not sure that the idea you propose would work with generic (non-affine) transforms, e.g. geographic projections. However, this prototype does seem to do something like what you're proposing, and further transformations could also be composed onto this.

asinghvi17 avatar Jun 13 '22 15:06 asinghvi17

Flipping the axis still doesn't solve the problem if you have a recipe that is only defined in one direction that you want to use with a recipe that is defined in the other direction. If you flip the axis you have the reverse issue. So we do need support for every plot object to flip it, however that is done most efficiently.

jkrumbiegel avatar Jun 14 '22 06:06 jkrumbiegel

Flipping the axis still doesn't solve the problem if you have a recipe that is only defined in one direction that you want to use with a recipe that is defined in the other direction.

I think this is the key point. I wonder whether this happens in practice. I was coming to the conclusion that flipping just one plot but not others on the same axis is mostly error-prone and shouldn't be supported. (At least, it would certainly confuse the scale / labelling mechanism of AlgebraOfGraphics.) Still, I could well be missing some important cases. Do you have a concrete example in mind when this is useful?

piever avatar Jun 14 '22 10:06 piever

I personally see the low level drawing mechanism as somewhat removed from a higher level view. In a way a density plot is just some patch, and people often create plots where they add elements not in a semantically congruent way, but just as a stopgap solution or because it quickly gets them their results. Of course AlgebraOfGraphics has to take a much stricter view on this. I imagine a scenario where you've added a bunch of plots to an axis, and the last thing you want to add is a density plot, but flipped. So now do you have to flip the axis and flip all other plotting functions' arguments as well?

jkrumbiegel avatar Jun 14 '22 12:06 jkrumbiegel

@asinghvi17 I would model geographic projections as a special case with 2 Domain space dimensions which map to a boolean (0-dimensional codomain) whether a domain space pixel is set or not. An image that's mapped becomes a 3d object, 2 domain spaces and color as codomain and so on.

@jkrumbiegel well yes, if at some point you want to flip everything then you would need to do that explicitly. But given the abstraction I propose it theoretically would be possible to just store the space objects in the plot object and make a modifying call that swaps the order of spaces. this could even be a method for permutedims!. Because with this abstraction we have semantic and syntactical equivalence, that is, the orientation solely depends on the order of arguments. By design. this would allow generic support of arbitrary plot directions.

rapus95 avatar Jun 25 '22 07:06 rapus95

Stumbling over it again, I thought it'd make sense to slightly restructure my idea and get at it from a different perspective. I also split it up into two different proposals which are entirely orthogonal.

Proposal 1: Introduce an AxisSpace type

Concept

struct AxisSpace{T} #T is the type of the values of the dimension. (Number, String, DateTime etc)
  limits::Tuple{T,T}
  label
  #...
end

struct Axis
  horizontal::AxisSpace
  vertical::AxisSpace
  #Axis3 would just get another AxisSpace
  title
  plots
  #...
end

Benefits

  • disentangles x/y from horizontal/vertical by extracting all x_ and y_ attributes into their respective AxisSpace objects and thus helps with the flexibility needed to solve #926
  • allows for instance-based axis linking
  • allows introducing a (holy-)trait-based interface for custom axis value types. For example to support rendering, tick'ing and labeling Unitful, DateTime and other non-number types by dispatching corresponding interface functions onto AxisSpace{T} with T being that type, DateTime for example as in #1347
  • reduces code duplication

Breaking?

We might be able to get there w/o breaking anything by redirecting all properties that start with x or y into the corresponding AxisSpace instance. If properly implemented, this shouldn't have any overhead at all due to constant propagation.

function Base.getproperty(a::Axis, sym::Symbol)
  tmp = string(sym)
  frst, rst = tmp[1], Symbol(tmp[2:end])
  if frst=='x'
    return getproperty(a.horizontal, rst)
  elseif frst=='y'
    return getproperty(a.vertical, rst)
  else
    return getfield(a, sym)
  end
end
#plus setters & constructors

Proposal 2: encode plot direction into wrapper types

Concept

might need better names for these types because those words are particularly related to mathematics (which I like though). Not meant to entirely replace the current syntax, but to complement it to always have a uniform and unambiguous way for describing the intention and concisely implementing it.

abstract type DimCoDim{T} end
abstract type AbstractDomain{T} <: DimCoDim{T} end 
abstract type AbstractCodomain{T} <: DimCoDim{T} end
struct Domain{T} <: AbstractDomain{T} #default that wraps the plot data
  data::T
end
struct Codomain{T} <: AbstractCodomain{T} #default that wraps the plot data
  data::T
end

#do actual work, optionally specialized for different axis data types
plot(p::CombinedPlot, hor::DimCoDim, ver::DimCoDim; kwargs...) = dowork(...)

# fallback that implements current meaning of argument order and plot direction
# and can be specialized for certain Plot types to use different defaults.
function plot(p::CombinedPlot, a, b; direction=:x, kwargs...)
  if direction==:x
    return plot(p, Codomain(b), Domain(a))
  else
    return plot(p, Domain(a), Codomain(b))
  end
end

Example

barplot(1:20, (1:20).^2) corresponds/expands to barplot(Domain(1:20), Codomain((1:20).^2))

Image

image

barplot(1:20, (1:20).^2, direction=:x) corresponds/expands to barplot(Codomain((1:20).^2), Domain(1:20))

Image

image

barplot((1:20).^2, 1:20) corresponds/expands to barplot(Domain((1:20).^2), Codomain(1:20))

Image

image

barplot((1:20).^2, 1:20, direction=:x) corresponds/expands to barplot(Codomain(1:20), Domain((1:20).^2))

Image

image

Benefits

  • the position always tells which data corresponds to which axis (first argument is horizontal, 2nd is vertical)
  • very good generalization towards higher dimensions
  • can help with semantic testing (in 3d, a curve always has 2 codimensions while a surface always has 1 codimension)
  • offers an unambiguous approach for #926

rapus95 avatar Jul 29 '22 17:07 rapus95