Makie.jl
Makie.jl copied to clipboard
Introduce AxisSpace type to generalize and solve multiple problems
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.
@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 😄
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 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.
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)

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

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.
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.
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?
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?
@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.
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

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

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

barplot((1:20).^2, 1:20, direction=:x) corresponds/expands to barplot(Codomain(1:20), Domain((1:20).^2))
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