Non-affine transformations in Makie

I would like to help with adding non-affine transformations to Makie. However, I haven’t used Makie very much yet and also I am aware that this is a fairly ambitious thing to do. I’m also not a graphics expert, just someone who likes Julia/Makie and didn’t enjoy fiddling with matplotlib’s dated API. For these reasons I am starting this thread as a place to discuss the general ideas.

Motivation, i.e. plots that could be supported:

  • polar plots (native)
  • crystallographic pole plots (external package)
  • stereographic plots (external package)
  • …others?

The difficult parts (probably):

  • contouring?
  • axis ticks/labels
  • interactivity (not really something I need but I think matplotlib can “zoom” polar plots)

Some coordinate transformations (including polar) are already part of CoordinateTransformations.jl, and Proj.jl implements a derived Transformation type. These are being used in GeoMakie.jl.

Makie has its own distinct Transformation type, which is part of the observables stack. The transformation types from the other mentioned packages would ideally just plug in somehow to the transform_func member of Makie.Transformation.

3D is very likely out of scope. In that sense, I could naïvely imagine two approaches:

  • Implement a transform_func attribute for the existing 2D Axis type that triggers the correct transformation setup
  • Write a new NonlinearAxis that is specifically for “weird” kind of plots like this, maybe some benefit from separation of concerns

I’ll come back with some code snippets soon, happy to hear any thoughts on what has or has not been attempted in this (transformed!) space.

There was also a first attempt at polar axes in this PR.

1 Like

Can’t quite grok the transformation stack yet, I’m hitting method errors.

For example:

using CairoMakie
using CoordinateTransformations

fig, ax, plt = scatter(1:10, 1:10)
to_polar = Observable{CoordinateTransformations.Transformation}(
# ^^^ MethodError, wants to do (::PolarFromCartesian)(::Float32)
Makie.Observables.connect!(ax.scene.transformation.transform_func, to_polar)
save("test.png", fig)

Tried overloading Makie.apply_transform without success. Also tried with this before the connect:

Makie.Observables.onany(PolarFromCartesian()) do trans
    to_polar[] = trans

In particular these lines in GeoMakie are mysterious to me. The docs for onany says that it takes Observables, but here it is being fed strings? I’m missing something.

I started an implementation as a Block (like Axis etc) in this branch Makie.jl/polaraxis.jl at jk/polar-axis · JuliaPlots/Makie.jl · GitHub but didn’t have time to continue. The transformation part is not difficult, the most work is probably as you say in the ticks etc. Also, Makie doesn’t have arbitrary clipping masks right now which means that plots can stick out of the circle. That part is mostly blocked by GLMakie having no other clipping mask concept than the rectangular boundary of Scenes currently.

There’s also another point that Makie currently doesn’t handle. Let’s say you plot a line with 2 points in a polar plot, from 0 to 2pi at some arbitrary radius. You would expect a circle but currently you would get a point, because all coordinates are first transformed and then plotted, the backend doesn’t take care that the line itself is transformed according to the transformation function. Because that is actually much more difficult to do generically. Usually the user would just be expected to pass data at a density that is high enough so that the straight line segments are not visible. I think matplotlib replaces line segments with curve approximations.

I also started on an implementation, but from the transformation end. This code prototype only runs on Scenes for now, but it shouldn’t be hard at all to change it to run on an LScene or similar type. It’s a bit easier on the interaction end since inverse_transform is also defined, and the transformation doesn’t change - only the limits of the Scene determine the aspect ratio.

# First, define the polar transformation as a Makie.PointTrans
# of the form (r, theta) -> (x, y)
trans = Makie.PointTrans{2}() do point
    y, x = point[1] .* sincos(point[2])
    return Point2f(x, y)
# Define its inverse (for interactivity)
Makie.inverse_transform(::typeof(trans)) = Makie.PointTrans{2}() do point
    Point2f(hypot(point[1], point[2]), atan(point[2], point[1]))

# Define a method to transform boxes from input space to transformed space
function Makie.apply_transform(f::typeof(trans), r::Rect2{T}) where {T}
    # TODO: once Proj4.jl is updated to PROJ 8.2, we can use
    # proj_trans_bounds (
    N = 21
    umin = vmin = T(Inf)
    umax = vmax = T(-Inf)
    xmin, ymin = minimum(r)
    xmax, ymax = maximum(r)
    # If ymax is 2π away from ymin, then the limits
    # are a circle, meaning that we only need the max radius
    # which is already known.
    if (ymax - ymin) ≈ 2π
        @assert xmin ≥ 0
        return Rect2f(
            Makie.apply_transform(f, Vec2(xmin, ymin)),
            Makie.apply_transform(f, Vec2f(xmax - xmin, prevfloat(2f0π)))
    for x in range(xmin, xmax; length = N)
        for y in range(ymin, ymax; length = N)
            u, v = Makie.apply_transform(f, Point(x, y))
            umin = min(umin, u)
            umax = max(umax, u)
            vmin = min(vmin, v)
            vmax = max(vmax, v)

    return Rect(Vec2(umin, vmin), Vec2(umax-umin, vmax-vmin))

# Define a method which will perform the correct limit adjustments
# such that our aspect ratio is 1
# This is somewhat configurable.
function MakieLayout.adjustlimits!(scene::Scene)
    asp = 1
    # We transform our limits to transformed space, since we can
    # operate linearly there
    target = Makie.apply_transform((scene.transformation.transform_func[]), Rect2f(Makie.boundingbox(scene)))
    area = scene.px_area[]

    # in the simplest case, just update the final limits with the target limits
    #if isnothing(asp) || width(area) == 0 || height(area) == 0
    #   la.finallimits[] = target
    #   return

    xlims = (left(target), right(target))
    ylims = (bottom(target), top(target))

    size_aspect = width(area) / height(area)
    data_aspect = (xlims[2] - xlims[1]) / (ylims[2] - ylims[1])

    aspect_ratio = data_aspect / size_aspect

    correction_factor = asp / aspect_ratio

    if correction_factor > 1
        # need to go wider
        # TODO: find appropriate autolimitmargins
        marginsum = 0#sum(la.xautolimitmargin[])
        ratios = if marginsum == 0
            (0.5, 0.5)
            (la.xautolimitmargin[] ./ marginsum)

        xlims = Makie.MakieLayout.expandlimits(xlims, ((correction_factor - 1) .* ratios)..., identity) # don't use scale here?
    elseif correction_factor < 1
        # need to go taller

        marginsum = 0#sum(la.yautolimitmargin[])
        ratios = if marginsum == 0
            (0.5, 0.5)
            (la.yautolimitmargin[] ./ marginsum)
        ylims = Makie.MakieLayout.expandlimits(ylims, (((1 / correction_factor) - 1) .* ratios)..., identity) # don't use scale here?

    bbox = BBox(xlims[1], xlims[2], ylims[1], ylims[2])
    Makie.update_cam!(scene, bbox)

sc = Scene(show_axis=false, camera = cam2d!)

sc.transformation.transform_func[] = trans
# draw "axis"
for r in 0:0.1:1
    lp = lines!(sc, fill(r, 100), LinRange(0, 2π, 100))
    translate!(lp, 0, 0, 100)


# draw plot

rs, θs = LinRange(0, 1, 100), LinRange(0, 2π, 100)
field = [exp(r) + cos(θ) for r in rs, θ in θs]
surface!(rs, θs, field; shading = false)

which yields a nice looking picture which Discourse isn’t letting me upload.

The advantage here is that we can use the normal tick finding algorithms, and possibly once nonlinear clip is implemented we can use that to create polar axes which don’t span all of (0, 2pi).

@jules, do you mind if I use your code as a base and integrate this into it?

Oh yeah I was just playing around a bit, I remember that I started with a weird implementation of the transform, there’s probably a better way, something like what you did. Yeah go ahead and play around with the implementation in my branch :slight_smile:

Alright, sat for an hour and worked out a basic axis (no protrusions or tick labels, but all the infrastructure is there) in this PR:

rs, θs = LinRange(0, 1, 100), LinRange(0, 2π, 100)
field = [exp(r) + cos(θ) for r in rs, θ in θs];

fig = Figure()
po = PolarAxis(fig[1, 1])
surface!(po.scene, rs, θs, field; shading = false)

(second picture with different theme)

I’ve committed the new version to your branch, and rebased it on current master. Essentially, it creates a PolarAxisTransformation struct which implements the Makie transform interface. This is useful since it can store values, specifically an offset angle and a direction.

The theme is still lacking a bit of documentation, and the tick labels still need to be implemented - but other than that I think the structure of the axis is mostly done! Also, I can’t plot to the axis for some reason - trying surface!(po, ...) gives an error:

ERROR: There is no current axis to plot into.
 [1] error(s::String)
   @ Base ./error.jl:35
 [2] plot!(::Type{Surface}, ::PolarAxis, ::Vararg{Any}; kw_attributes::Base.Pairs{Symbol, Bool, Tuple{Symbol}, NamedTuple{(:shading,), Tuple{Bool}}})
   @ Makie ~/.julia/dev/Makie/src/figureplotting.jl:45
 [3] surface!(::PolarAxis, ::Vararg{Any}; attributes::Base.Pairs{Symbol, Bool, Tuple{Symbol}, NamedTuple{(:shading,), Tuple{Bool}}})
   @ MakieCore ~/.julia/dev/Makie/MakieCore/src/recipes.jl:37
 [4] top-level scope
   @ REPL[334]:1

and adding MakieLayout.can_be_current_axis(::PolarAxis) = true causes a stack overflow with no other error message.


Thanks, this was faster than I expected! I’ll have to set some time for understanding this but I may be able to help with documentation on the weekend or at least some examples.

1 Like

That would be great! Let me know if this works for the crystallographic plots as well - would love to see the results of that.

Also, please feel free to add review comments to the PR if there’s anything which looks like it needs more detailed explanation, or if there’s something which doesn’t make sense.

This looks fantastic! I’m glad this project is moving forward. I’ll follow the PR and try to help out where I can.