Help with a new routine to add labels to the tail of each plot

I’m trying to implement a ‘tail_labels’ function that labels the tail of the plot with the text label. It handles all the messy layout details for you.

Here is an example from a prototype:

But there are a bunch of things that don’t work.

  1. I’d like to be able to draw outside the axis.
  2. I’d love to be able to add padding on the right so the labels exist outside the ‘axis’
  3. I’d love an interface that’s largely as simple as legend.

I think all of this is possible. But I’ll need some help navigating the Makie plotting routines to make it easy.

So I hope this is an easy question that I don’t know how to do right now.

How can I modify the get_last_point_in_pixels to give me pixel coordinates in the figure scene rather than the axis scene? I tried stuff from Makie: Put text at arbitrary position outside the frame - #4 by ryofurue but that seemed to still me the wrong coordinates when I plotted into the figure scene.

Thanks!

David

# This tail_labels_pava function / pava_l1 code 
# was produced by ChatGPT in the following log
# https://chatgpt.com/share/67bbd55f-7c58-8006-bcb7-38eebf37a676
# A very impressive use of ChatGPT :) 
# I still think there's probably a more optimal way to do it, but
# this was much faster than I expected. 
# It's possible this is copied from somewhere... 

using Statistics  # for median()
"""
    pava_l1(d::Vector{Float64}) -> Vector{Float64}

Perform the \$L^1\$ Pool Adjacent Violators Algorithm on the data `d`, enforcing
a nondecreasing fitted sequence x₁ <= x₂ <= ... <= xₙ that minimizes
∑|xᵢ - dᵢ|.

Returns a vector `x` of the same length as `d`.
"""
function pava_l1(d::Vector{T}) where {T <: Real}
    n = length(d)

    # Each "block" on the stack will be stored as a NamedTuple containing:
    #   values :: Vector{Float64}   (the original d_i's in this block)
    #   rep    :: Float64          (the median of those d_i's)
    #   start  :: Int              (block covers indices start through end)
    #   end    :: Int
    blocks = []

    for i in 1:n
        # Start a new block for the single point d[i]
        push!(blocks, (values = [d[i]],
                       rep    = d[i],
                       start  = i,
                       last   = i))
        # Now check if the new rightmost block violates monotonicity
        # with the block immediately to its left:
        while length(blocks) > 1
            lastblock    = blocks[end]
            secondlast   = blocks[end-1]

            # We want the final x-sequence to be nondecreasing:
            # If the left block's representative is greater than
            # the right block's representative, that's a violation.
            if secondlast.rep > lastblock.rep
                # Merge these two blocks
                merged_values = vcat(secondlast.values, lastblock.values)
                merged_rep    = median(merged_values)
                merged_block  = (values = merged_values,
                                 rep    = merged_rep,
                                 start  = secondlast.start,
                                 last   = lastblock.last)
                pop!(blocks)  # remove old last
                pop!(blocks)  # remove old second-last
                push!(blocks, merged_block)
            else
                break
            end
        end
    end

    # Now expand the blocks out to form the final fitted x array
    x = Vector{Float64}(undef, n)
    for b in blocks
        @inbounds for j in b.start : b.last
            x[j] = b.rep
        end
    end
    return x
end

using Statistics

"""
    tail_labels_pava(points, heights) -> Vector{Float64}

Solve the "tail label" placement:
  minimize ∑ |ℓᵢ - points[i]| 
  subject to ℓᵢ ≥ ℓᵢ₋₁ + heights[i-1], for i=2..n.

Implements an \$L^1\$ isotonic regression via a PAVA approach.
"""
function tail_labels_pava(points::Vector{T1}, heights::Vector{T2}) where {T1 <: Real, T2 <: Real}
    @assert length(points) == length(heights) "points and heights must have the same length"
    n = length(points)
    if n == 0
        return Float64[]
    end
    # Precompute cumulative heights to shift each pᵢ => dᵢ
    # For convenience, define:
    #   offset[i] = sum(h₁, h₂, ..., hᵢ) but offset[1] = 0
    # so dᵢ = pᵢ - offset[i].
    offset = cumsum([0.0; heights[1:end-1]])  # length n
    # Construct dᵢ
    d = Vector{Float64}(undef, n)
    for i in 1:n
        d[i] = points[i] - offset[i]
    end

    # Run the PAVA to get xᵢ
    x = pava_l1(d)

    # Finally, map xᵢ back to ℓᵢ = xᵢ + offset[i]
    ℓ = Vector{Float64}(undef, n)
    for i in 1:n
        ℓ[i] = x[i] + offset[i]
    end

    return ℓ
end

## My code starts here

# How do we modify this to get the coordinates in the overall figure scene? 
function get_last_point_in_pixels(plt, plot)
  pts = plt[1][] # get the points 
  # find the last point
  p = argmax(x->x[1],pts)
  # translate the point to pixel space
  pp = Makie.plot_to_screen(plot, p)
  return pp 
end 
function last_points(ax::Axis, p::Plot)
  plts,labels = Makie.get_labeled_plots(ax; unique=false,merge=false)
  last_points = map(line->get_last_point_in_pixels(line, p), plts)
  obs = Observable{Vector{Point2f}}(last_points)
  onany(plts, ax.scene.camera, ax.scene.camera.pixel_space, ax.scene.camera.projectionview) do plts, _, _, _
    last_points = map(line->get_last_point_in_pixels(line, p), plts)
    obs[] = last_points
  end
  colors = map(p->p.color, plts)
  return obs, labels, colors 
end
function tail_labels!(ax::Axis, p::Plot; text_style=(;), 
    lineoffset=Point2f(5,0),
    textoffset=Point2f(10,10))
  # get the last points as observables. 
  tail_points, labels, colors = last_points(ax, p)

  # now, for each label, we want to get the height of the bounding box of the label
  # to do so, let's allocate a vector of locations for each bounding box
  locations = map(tail_points) do p 
    p
  end
  # create a vector of text elements. 
  texts = map(enumerate(labels)) do (i, label)
    text!(ax, @lift $locations[i] .+ textoffset; 
      text=label, space=:pixel, align = (:left, :center), 
      color=colors[i],
      text_style...)
  end
  heights = map(texts) do text
    height(Makie.data_limits(text))
  end 

  # now we need to solve the problem...
  onany(tail_points) do args
    pts = args
    # update heights
    heights = map(texts) do text
      height(Makie.data_limits(text))
    end 
    p = sortperm(map(pt->pt[2], pts))
    ylocations = tail_labels_pava(map(pt->pt[2], pts[p]), heights)
    # now we need to update the locations of the texts
    ip = invperm(p) 
    # look at them in the inverse order... 
    locations[] = map(enumerate(ylocations[ip])) do (i, y)
      Point2f(pts[i][1], y)
    end
  end
  
  linesegments = @lift map(
      x ->(x[1] .+ lineoffset, x[2] .+ textoffset), 
      zip($tail_points, $locations))
  
  linesegments!(ax, linesegments; linewidth=1.0, color=:lightgrey, space=:pixel)

  return texts, heights, locations
end 

tail_labels!(ax::Axis; kwargs...) = tail_labels!(ax, first(ax.scene.plots); kwargs...)


using CairoMakie
f = lines(cumsum(randn(100).^2);  label="Test", axis=(yscale=log10,))
lines!(f.axis, cumsum(randn(100).^2) ./ 2; label = "Test / 2")
lines!(f.axis, cumsum(randn(100).^2) ./ 1.5; label = "Test / 1.5")
lines!(f.axis, cumsum(randn(100).^2) ./ 6; label = "Test / 6")
lines!(f.axis, cumsum(randn(100).^2) ./ 7; label = "Test / 7")
t = tail_labels!(f.axis, f.plot; textoffset = Point2f(20,0))
xlims!(f.axis, 0, 125)
ylims!(f.axis, nothing, 150)
hidespines!(f.axis)
f
4 Likes

If you want to do this really well, I’d probably make a new Block type like TailLegend or so. This one would consist of a campixel! scene to plot the labels into. The scene’s width can be auto-determined given the size of the text plots inside, and then the whole block can adopt that width. You can check the code for Label to compare how that can be done, for example. Such a Block could then be placed at f[x, y, Right()] such that it will increase the protrusion space of the axis layout cell on that side.

f = Figure()
ax = Axis(f[1, 1])
Axis(f[2, 1])
Axis(f[2, 2])
Axis(f[1, 2])
leg = LScene(f[1, 1, Right()], scenekw = (; camera = campixel!), show_axis = false)
padding = 5
for i in 1:5
    l = lines!(ax, (1:10) .* i)
    y = i * 30 # this should automatically change given the projected ends of the lines
    text!(leg, padding, y, text = "Label $i", color = l.color)
end
textbb = mapreduce(x -> Makie.boundingbox(x, :pixel), union, leg.scene.plots)
leg.width = textbb.widths[1] + 2 * padding
f

You could also use a single text plot with an array to make it more efficient, this is just a rough sketch.

3 Likes

Thanks for that!

It seems like the pixel coordinates seem to transfer between the two scenes so that this works.


using CairoMakie

function get_last_point_in_pixels(plt, plot)
  pts = plt[1][] # get the points 
  # find the last point
  p = argmax(x->x[1],pts)
  # translate the point to pixel space
  pp = Makie.plot_to_screen(plot, p)
  return pp 
end 
function last_point(plot, ax)
  obs = Observable{Point2f}(get_last_point_in_pixels(plot, plot))
  onany(ax.scene.camera, ax.scene.camera.pixel_space, ax.scene.camera.projectionview) do _, _, _
    obs[] = get_last_point_in_pixels(plot, plot)
  end
  return obs 
end 

f = Figure()
ax = Axis(f[1, 1])
Axis(f[2, 1])
Axis(f[2, 2])
Axis(f[1, 2])
leg = LScene(f[1, 1, Right()], scenekw = (; camera = campixel!), show_axis = false)
padding = 10
y = map(1:5) do i
    Observable(Point2f(0, i*33))
end

for i in 1:5
    l = lines!(ax, (1:10) .* i*randn())
    y[i] = last_point(l, ax)
    y2 = @lift $(y[i])[2]
    textpos = @lift Point2f(padding, $(y[i])[2])
    text!(leg, padding, y2, text = "Label $i", color = l.color, align = (:left, :center), space = :pixel)
    
    linepts = @lift [(Point2f(padding-20, $(y2)), 
                      Point2f(padding-5, $(y2))
          )]
    @show linepts
    linesegments!(leg, linepts, color = :lightgrey)
end
textbb = mapreduce(x -> Makie.boundingbox(x, :pixel), union, leg.scene.plots)
leg.width = textbb.widths[1] + 5 * padding
hidespines!(ax)
f

I can think of some scenarios where you might want the labeling lines to “go into” the axis. Is there any way to make that work?

I’ll have to hook this up to the auto placement option next. (That just needs a little rejiggering…)

1 Like

You can’t draw a line between two scenes. But you can draw into fig.scene for example which covers everything. Or you put your own scene on top of both, then the layout placement would be different though

Is there some easy way to get the overall fig.scene coordinates for a given pixel in a subscene?

If not easy, is there some more complicated way to do it?

e.g. if I take in the axis and overall fig.scene, can I get the coordinates of the axis scene in the fig.scene?

function get_last_point_in_pixels(plt, plot, subscene::Scene, figscene::Scene)
  pts = plt[1][] # get the points 
  # find the last point
  p = argmax(x->x[1],pts)
  # translate the point to pixel space
  pp = Makie.plot_to_screen(plot, p)
  # TODO 
  # map the pixel location pp in subscene to a pixel location in figscene 
  return pp 
end 

You add the offset of scene.viewport to your projected point

Thanks! this has been super helpful. Really appreciate it.

I’ll work on an improved version with these ideas and then see if there’s any additional feedback.

@dgleich since nobody mention it yet, perhaps you want to contribute this function to MakieExtra.jl ? Seems to be exactly the kind of repo that would host such a function (cc @aplavin )

Indeed, would be great to turn this into a nice reusable function and package!

I’ve implemented this functionality years ago for PyPlot (legend_inline_right(), implementation in PyPlotUtils.jl/src/legend.jl at master · JuliaAPlavin/PyPlotUtils.jl · GitHub), but never got to translating it to Makie.
Definitely miss it every once in a while :slight_smile: