Can I align all vertical axes in a multi-row faceted plot done with Plots.jl?

I tried to do a faceted plot using Plots.jl. The code below does almost everything I wanted,
except for one thing: the vertical axes are aligned on each row, but not across rows. The argument
link = :y in the last Plots.plot function was supposed to align all vertical axes, but it looks
like it is aligning only the vertical axes in each row.

Note: Alignment depends on the specific random numbers generated. Occasionally all vertical axes do align.

Is there a way to align all vertical axes?

using Plots

dfd = DataFrame(
    :Date => [sum([Date(Dates.now()), Dates.Day(i)]) for i in 1:60],
    :Return => randn(60) ./ 100.0,
    :A => repeat(collect(1:5), inner = 12),
    :B => repeat(collect(1:12), inner = 5)
)

gdf = groupby(dfd, [:B])

n_rows = 3
n_cols = div(length(gdf), 3)

p = [
    Plots.plot(
        gdf[i].Date,
        gdf[i].Return,
        legend_position = false,
        rotation = 45,
        title = "B = $i",
        titlefontsize = 8,
        xtickfontsize = 6,
        yticks = i % n_cols == 1,
        ytickfontsize = 6) for i in 1:12
]

plt_panel = Plots.plot(
    p...,
    link=:y,
    size = (4000 / 4, 700),
    layout = (n_rows, n_cols),
    legend = false,
    plot_title = "Hello"
)

display(plt_panel)

After having defined p, you could do:

yl = (minimum(first.(ylims.(p))), maximum(last.(ylims.(p))))

and then use plot() keyword argument ylims = yl (instead of link=:y), to obtain:

1 Like

This is a really interesting discussion, and it made me start looking for out-of-the box solutions that might be floating around already. I see that @rafael.guerra also has a similar post using StatsPlots.jl, but I guess the problem there is that some extra work would still need to be done to hide shared axis decorations and switch from an auto generated legend to a title? Trying it out on the above still gets us pretty far I think:

using Plots, DataFrames, Dates

dfd = DataFrame(
    Date = (today() + Day(1)) : Day(1) : (today() + Day(60)),
    Return = randn(60) / 100.0,
    A = repeat(1:5; inner=12),
    B = repeat(1:12; inner=5),
)

@df dfd Plots.plot(:Date, :Return;
    group = {B = :B},
    layout = 12,
    size = (1_000, 700),
    rotation = 45,
    xtickfontsize = 6,
    ytickfontsize = 6,
    plot_title = "Hello",
    ylims = extrema(:Return),
)

This made me curious if AlgebraOfGraphics could get us the rest of the way there, but it looks like there are a couple of trade-offs, IIUC:

using CairoMakie, AlgebraOfGraphics

plt = data(dfd) * mapping(:Date, :Return;
    layout = :B => nonnumeric,
) *
visual(Lines)

fg = draw(plt;
    figure = (; resolution = (1_000, 700)),
    axis = (;
        xticklabelrotation = π/4,
        yticklabelrotation = π/4,
        xticks = datetimeticks(identity, dfd.Date),   
        xticklabelsize = 10,
        yticklabelsize = 10,
    ),
    facet = (; linkxaxes=:none),
)

Label(fg.figure[0, :], "Hello";
    textsize = 28,
    font = AlgebraOfGraphics.firasans("Medium"),
)

fg

A few of the benefits include computing the number of subplots we need automatically, hiding shared axis decorations, handling the global data limits, and applying common axis labels. The remaining downsides I am seeing are that we need to manually specify the xticks with a helper function for now, and stick to numerical facet labels to get the ordering right (i.e., doing something like layout = :B => (x -> "B = $x") would change the ordering according to sort(string.(1:12)), instead of following the natural sorting via something like NaturalSort.jl). I could be misunderstanding some of the more technical points here, so maybe @piever might have a better solution?

Anyway, I’m realizing that this thread was originally about Plots.jl, so I’d be happy to split this discussion off if that would be better

1 Like

Something seems to be broken in Plots.jl for this problem.

Following the OP code, if we work with the plot keyword arguments ylims, ytickfontsize and y_foreground_color_text, we can get a decent result:

Plots.jl code
using Plots, DataFrames, Dates, Measures

dfd = DataFrame(
    :Date => [sum([Date(Dates.now()), Dates.Day(i)]) for i in 1:60],
    :Return => randn(60) ./100,
    :A => repeat(collect(1:5), inner = 12),
    :B => repeat(collect(1:12), inner = 5)
)

gdf = groupby(dfd, [:B])

n_rows = 3
n_cols = div(length(gdf), 3)

p = [ Plots.plot(gdf[i].Date, gdf[i].Return, legend_position = false,
       rotation = 45, title = "B = $i", titlefontsize = 8, xtickfontsize = 6,
       ygrid = true, yticks = true, ytickfontsize = (i%n_cols==1) ? 6 : 1,
       y_foreground_color_text = (i%n_cols==1) ? :black : :transparent) for i in 1:12
]

yl = extrema(dfd.Return)

plt_panel = Plots.plot(p..., ylims = yl,
    size = (4000 / 4, 700), layout = (n_rows, n_cols),
    legend = false, plot_title = "Hello", bottom_margin = 5mm
)

Thanks. I had noticed and was going to delete my post.

I noticed two small differences between your two solutions:

  1. The first one does not have tick marks on the vertical axes (except for the leftmost chart). The second one has tick marks on all vertical axes.

  2. The space between the charts in one row is smaller in the first solution.

Ideally the best of the two solutions should be combined: smaller space between charts and tick marks in all axes. Is that possible?

Yes, it is possible. We need to apply appropriate logic to the left_margin and right_margin keyword arguments to get:

Plots.jl code updated
using Plots, DataFrames, Dates, Measures

dfd = DataFrame(
    :Date => [sum([Date(Dates.now()), Dates.Day(i)]) for i in 1:60],
    :Return => randn(60) ./100,
    :A => repeat(collect(1:5), inner = 12),
    :B => repeat(collect(1:12), inner = 5)
)

gdf = groupby(dfd, [:B])

n_rows = 3
n_cols = div(length(gdf), 3)

p = [ Plots.plot(gdf[i].Date, gdf[i].Return, legend_position = false,
       rotation = 45, title = "B = $i", titlefontsize = 8, xtickfontsize = 6,
       ygrid = true, yticks = true, ytickfontsize = (i%n_cols==1) ? 6 : 1,
       y_foreground_color_text = (i%n_cols==1) ? :black : :transparent,
       left_margin  = (i%n_cols==1) ?  5mm : -2mm,
       right_margin = (i%n_cols==0) ?  2mm : -2mm) for i in 1:12
]

yl = extrema(dfd.Return)

plt_panel = Plots.plot(p..., ylims = yl,
    size = (4000 / 4, 700), layout = (n_rows, n_cols),
    legend = false, plot_title = "Hello",  bottom_margin = 5mm
)

2 Likes

A bit late to the party (and sorry for the OT), but I just wanted to mention that there are a couple of helper functions to reorder / customize labels of a grouping variable.