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.
- I’d like to be able to draw outside the axis.
- I’d love to be able to add padding on the right so the labels exist outside the ‘axis’
- 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