From c61695da07c6079f9f0d478df77e2abbea50decd Mon Sep 17 00:00:00 2001 From: ffreyer Date: Tue, 3 Mar 2026 03:19:11 +0100 Subject: [PATCH 01/52] refactor LineAxis, Axis to ComputeGraph --- Makie/src/makielayout/blocks/axis.jl | 425 ++++++++-------- Makie/src/makielayout/blocks/colorbar.jl | 5 +- Makie/src/makielayout/defaultattributes.jl | 5 + Makie/src/makielayout/lineaxis.jl | 544 +++++++++++---------- Makie/src/makielayout/types.jl | 10 +- 5 files changed, 505 insertions(+), 484 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 20367073f58..29b612accd9 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -1,12 +1,10 @@ -function update_gridlines!(grid_obs::Observable{Vector{Point2f}}, offset::Point2f, tickpositions::Vector{Point2f}) - result = grid_obs[] - empty!(result) # reuse array for less allocations +function gridline_points(offset::Point2f, tickpositions::Vector{Point2f}) + result = Point2f[] for gridstart in tickpositions opposite_tickpos = gridstart .+ offset push!(result, gridstart, opposite_tickpos) end - notify(grid_obs) - return + return result end function process_axis_event(ax, event) @@ -86,8 +84,7 @@ function update_axis_camera(scene::Scene, t, lims, xrev::Bool, yrev::Bool) return end - -function calculate_title_position(area, titlegap, subtitlegap, align, xaxisposition, xaxisprotrusion, _, ax, subtitlet) +function calculate_title_position(area, titlegap, align, xaxisposition, xaxisprotrusion, subtitle_height) local x::Float32 = if align === :center area.origin[1] + area.widths[1] / 2 elseif align === :left @@ -98,11 +95,7 @@ function calculate_title_position(area, titlegap, subtitlegap, align, xaxisposit error("Title align $align not supported.") end - local subtitlespace::Float32 = if ax.subtitlevisible[] && !iswhitespace(ax.subtitle[]) - boundingbox(subtitlet, :data).widths[2] + subtitlegap - else - 0.0f0 - end + subtitlespace::Float32 = subtitle_height local yoffset::Float32 = top(area) + titlegap + (xaxisposition === :top ? xaxisprotrusion : 0.0f0) + subtitlespace @@ -111,42 +104,17 @@ function calculate_title_position(area, titlegap, subtitlegap, align, xaxisposit end function compute_protrusions( - title, titlesize, titlegap, titlevisible, spinewidth, - topspinevisible, bottomspinevisible, leftspinevisible, rightspinevisible, - xaxisprotrusion, yaxisprotrusion, xaxisposition, yaxisposition, - subtitle, subtitlevisible, subtitlesize, subtitlegap, titlelineheight, subtitlelineheight, - subtitlet, titlet + titleheight, subtitleheight, + xaxisposition, xaxisprotrusion, + yaxisposition, yaxisprotrusion, ) - local left::Float32, right::Float32, bottom::Float32, top::Float32 = 0.0f0, 0.0f0, 0.0f0, 0.0f0 - - if xaxisposition === :bottom - bottom = xaxisprotrusion - else - top = xaxisprotrusion - end - - titleheight = boundingbox(titlet, :data).widths[2] + titlegap - subtitleheight = boundingbox(subtitlet, :data).widths[2] + subtitlegap - - titlespace = if !titlevisible || iswhitespace(title) - 0.0f0 - else - titleheight - end - subtitlespace = if !subtitlevisible || iswhitespace(subtitle) - 0.0f0 - else - subtitleheight - end - - top += titlespace + subtitlespace + left::Float32 = ifelse(yaxisposition === :left, yaxisprotrusion, 0.0f0) + right::Float32 = ifelse(yaxisposition === :right, yaxisprotrusion, 0.0f0) + bottom::Float32 = ifelse(xaxisposition === :bottom, xaxisprotrusion, 0.0f0) + top::Float32 = ifelse(xaxisposition === :top, xaxisprotrusion, 0.0f0) - if yaxisposition === :left - left = yaxisprotrusion - else - right = yaxisprotrusion - end + top += titleheight + subtitleheight return GridLayoutBase.RectSides{Float32}(left, right, bottom, top) end @@ -183,6 +151,9 @@ function initialize_block!(ax::Axis; palette = nothing) # or the other way around connect_conversions!(scene.conversions, ax) + # TODO: maybe use scenearea instead? + add_input!(ax.attributes, :viewport, scene.viewport) + setfield!(scene, :float32convert, Float32Convert()) if !isnothing(palette) @@ -193,7 +164,10 @@ function initialize_block!(ax::Axis; palette = nothing) # TODO: replace with mesh, however, CairoMakie needs a poly path for this signature # so it doesn't rasterize the scene - background = poly!(blockscene, scenearea; color = ax.backgroundcolor, inspectable = false, shading = NoShading, strokecolor = :transparent) + background = poly!( + blockscene, scenearea; color = ax.backgroundcolor, inspectable = false, + shading = NoShading, strokecolor = :transparent + ) translate!(background, 0, 0, -100) elements[:background] = background @@ -203,42 +177,6 @@ function initialize_block!(ax::Axis; palette = nothing) ax.xaxislinks = Axis[] ax.yaxislinks = Axis[] - xgridnode = Observable(Point2f[]; ignore_equal_values = true) - xgridlines = linesegments!( - blockscene, xgridnode, linewidth = ax.xgridwidth, visible = ax.xgridvisible, - color = ax.xgridcolor, linestyle = ax.xgridstyle, inspectable = false - ) - # put gridlines behind the zero plane so they don't overlay plots - translate!(xgridlines, 0, 0, -10) - elements[:xgridlines] = xgridlines - - xminorgridnode = Observable(Point2f[]; ignore_equal_values = true) - xminorgridlines = linesegments!( - blockscene, xminorgridnode, linewidth = ax.xminorgridwidth, visible = ax.xminorgridvisible, - color = ax.xminorgridcolor, linestyle = ax.xminorgridstyle, inspectable = false - ) - # put gridlines behind the zero plane so they don't overlay plots - translate!(xminorgridlines, 0, 0, -10) - elements[:xminorgridlines] = xminorgridlines - - ygridnode = Observable(Point2f[]; ignore_equal_values = true) - ygridlines = linesegments!( - blockscene, ygridnode, linewidth = ax.ygridwidth, visible = ax.ygridvisible, - color = ax.ygridcolor, linestyle = ax.ygridstyle, inspectable = false - ) - # put gridlines behind the zero plane so they don't overlay plots - translate!(ygridlines, 0, 0, -10) - elements[:ygridlines] = ygridlines - - yminorgridnode = Observable(Point2f[]; ignore_equal_values = true) - yminorgridlines = linesegments!( - blockscene, yminorgridnode, linewidth = ax.yminorgridwidth, visible = ax.yminorgridvisible, - color = ax.yminorgridcolor, linestyle = ax.yminorgridstyle, inspectable = false - ) - # put gridlines behind the zero plane so they don't overlay plots - translate!(yminorgridlines, 0, 0, -10) - elements[:yminorgridlines] = yminorgridlines - # When the transform function (xscale, yscale) of a plot changes we # 1. communicate this change to plots (barplot needs this to make bars # compatible with the new transform function/scale) @@ -260,10 +198,7 @@ function initialize_block!(ax::Axis; palette = nothing) update_axis_camera(scene, args...) end - xaxis_endpoints = lift( - blockscene, ax.xaxisposition, scene.viewport; - ignore_equal_values = true - ) do xaxisposition, area + map!(ax.attributes, [:xaxisposition, :viewport], :xaxis_endpoints) do xaxisposition, area if xaxisposition === :bottom return bottomline(Rect2f(area)) elseif xaxisposition === :top @@ -273,10 +208,7 @@ function initialize_block!(ax::Axis; palette = nothing) end end - yaxis_endpoints = lift( - blockscene, ax.yaxisposition, scene.viewport; - ignore_equal_values = true - ) do yaxisposition, area + map!(ax.attributes, [:yaxisposition, :viewport], :yaxis_endpoints) do yaxisposition, area if yaxisposition === :left return leftline(Rect2f(area)) elseif yaxisposition === :right @@ -286,69 +218,46 @@ function initialize_block!(ax::Axis; palette = nothing) end end - xaxis_flipped = lift(x -> x === :top, blockscene, ax.xaxisposition; ignore_equal_values = true) - yaxis_flipped = lift(x -> x === :right, blockscene, ax.yaxisposition; ignore_equal_values = true) - - xspinevisible = lift( - blockscene, xaxis_flipped, ax.bottomspinevisible, ax.topspinevisible; - ignore_equal_values = true - ) do xflip, bv, tv - xflip ? tv : bv - end - xoppositespinevisible = lift( - blockscene, xaxis_flipped, ax.bottomspinevisible, ax.topspinevisible; - ignore_equal_values = true - ) do xflip, bv, tv - xflip ? bv : tv - end - yspinevisible = lift( - blockscene, yaxis_flipped, ax.leftspinevisible, ax.rightspinevisible; - ignore_equal_values = true - ) do yflip, lv, rv - yflip ? rv : lv - end - yoppositespinevisible = lift( - blockscene, yaxis_flipped, ax.leftspinevisible, ax.rightspinevisible; - ignore_equal_values = true - ) do yflip, lv, rv - yflip ? lv : rv - end - xspinecolor = lift( - blockscene, xaxis_flipped, ax.bottomspinecolor, ax.topspinecolor; - ignore_equal_values = true - ) do xflip, bc, tc - xflip ? tc : bc - end - xoppositespinecolor = lift( - blockscene, xaxis_flipped, ax.bottomspinecolor, ax.topspinecolor; - ignore_equal_values = true - ) do xflip, bc, tc - xflip ? bc : tc - end - yspinecolor = lift( - blockscene, yaxis_flipped, ax.leftspinecolor, ax.rightspinecolor; - ignore_equal_values = true - ) do yflip, lc, rc - yflip ? rc : lc - end - yoppositespinecolor = lift( - blockscene, yaxis_flipped, ax.leftspinecolor, ax.rightspinecolor; - ignore_equal_values = true - ) do yflip, lc, rc - yflip ? lc : rc - end + map!(x -> x === :top, ax.attributes, :xaxisposition, :xaxis_flipped) + map!(x -> x === :right, ax.attributes, :yaxisposition, :yaxis_flipped) + + map!( + (flip, bv, tv) -> ifelse(flip, (tv, bv), (bv, tv)), + ax.attributes, + [:xaxis_flipped, :bottomspinevisible, :topspinevisible], + [:xspinevisible, :xoppositespinevisible] + ) + map!( + (flip, lv, rv) -> ifelse(flip, (rv, lv), (lv, rv)), + ax.attributes, + [:yaxis_flipped, :leftspinevisible, :rightspinevisible], + [:yspinevisible, :yoppositespinevisible] + ) + map!( + (flip, bc, tc) -> ifelse(flip, (tc, bc), (bc, tc)), + ax.attributes, + [:xaxis_flipped, :bottomspinecolor, :topspinecolor], + [:xspinecolor, :xoppositespinecolor] + ) + map!( + (flip, lc, rc) -> ifelse(flip, (rc, lc), (lc, rc)), + ax.attributes, + [:yaxis_flipped, :leftspinecolor, :rightspinecolor], + [:yspinecolor, :yoppositespinecolor] + ) xlims = lift(xlimits, blockscene, finallimits; ignore_equal_values = true) ylims = lift(ylimits, blockscene, finallimits; ignore_equal_values = true) xaxis = LineAxis( - blockscene, endpoints = xaxis_endpoints, limits = xlims, - flipped = xaxis_flipped, ticklabelrotation = ax.xticklabelrotation, + blockscene, ComputePipeline.ComputeGraphView(ax.attributes, :xaxis), + endpoints = ax.xaxis_endpoints, limits = xlims, + flipped = ax.xaxis_flipped, ticklabelrotation = ax.xticklabelrotation, ticklabelalign = ax.xticklabelalign, labelsize = ax.xlabelsize, labelpadding = ax.xlabelpadding, ticklabelpad = ax.xticklabelpad, labelvisible = ax.xlabelvisible, label = ax.xlabel, labelfont = ax.xlabelfont, labelrotation = ax.xlabelrotation, ticklabelfont = ax.xticklabelfont, ticklabelcolor = ax.xticklabelcolor, labelcolor = ax.xlabelcolor, tickalign = ax.xtickalign, ticklabelspace = ax.xticklabelspace, dim_convert = ax.dim1_conversion, ticks = ax.xticks, tickformat = ax.xtickformat, ticklabelsvisible = ax.xticklabelsvisible, - ticksvisible = ax.xticksvisible, spinevisible = xspinevisible, spinecolor = xspinecolor, spinewidth = ax.spinewidth, + ticksvisible = ax.xticksvisible, spinevisible = ax.xspinevisible, spinecolor = ax.xspinecolor, spinewidth = ax.spinewidth, ticklabelsize = ax.xticklabelsize, trimspine = ax.xtrimspine, ticksize = ax.xticksize, reversed = ax.xreversed, tickwidth = ax.xtickwidth, tickcolor = ax.xtickcolor, minorticksvisible = ax.xminorticksvisible, minortickalign = ax.xminortickalign, minorticksize = ax.xminorticksize, minortickwidth = ax.xminortickwidth, minortickcolor = ax.xminortickcolor, minorticks = ax.xminorticks, scale = ax.xscale, @@ -360,13 +269,14 @@ function initialize_block!(ax::Axis; palette = nothing) ax.xaxis = xaxis yaxis = LineAxis( - blockscene, endpoints = yaxis_endpoints, limits = ylims, - flipped = yaxis_flipped, ticklabelrotation = ax.yticklabelrotation, + blockscene, ComputePipeline.ComputeGraphView(ax.attributes, :yaxis), + endpoints = ax.yaxis_endpoints, limits = ylims, + flipped = ax.yaxis_flipped, ticklabelrotation = ax.yticklabelrotation, ticklabelalign = ax.yticklabelalign, labelsize = ax.ylabelsize, labelpadding = ax.ylabelpadding, ticklabelpad = ax.yticklabelpad, labelvisible = ax.ylabelvisible, label = ax.ylabel, labelfont = ax.ylabelfont, labelrotation = ax.ylabelrotation, ticklabelfont = ax.yticklabelfont, ticklabelcolor = ax.yticklabelcolor, labelcolor = ax.ylabelcolor, tickalign = ax.ytickalign, ticklabelspace = ax.yticklabelspace, dim_convert = ax.dim2_conversion, ticks = ax.yticks, tickformat = ax.ytickformat, ticklabelsvisible = ax.yticklabelsvisible, - ticksvisible = ax.yticksvisible, spinevisible = yspinevisible, spinecolor = yspinecolor, spinewidth = ax.spinewidth, + ticksvisible = ax.yticksvisible, spinevisible = ax.yspinevisible, spinecolor = ax.yspinecolor, spinewidth = ax.spinewidth, trimspine = ax.ytrimspine, ticklabelsize = ax.yticklabelsize, ticksize = ax.yticksize, flip_vertical_label = ax.flip_ylabel, reversed = ax.yreversed, tickwidth = ax.ytickwidth, tickcolor = ax.ytickcolor, minorticksvisible = ax.yminorticksvisible, minortickalign = ax.yminortickalign, minorticksize = ax.yminorticksize, minortickwidth = ax.yminortickwidth, minortickcolor = ax.yminortickcolor, minorticks = ax.yminorticks, scale = ax.yscale, @@ -377,9 +287,10 @@ function initialize_block!(ax::Axis; palette = nothing) ax.yaxis = yaxis - xoppositelinepoints = lift( - blockscene, scene.viewport, ax.spinewidth, ax.xaxisposition; - ignore_equal_values = true + map!( + ax.attributes, + [:viewport, :spinewidth, :xaxisposition], + :xoppositelinepoints ) do r, sw, xaxpos if xaxpos === :top y = bottom(r) @@ -394,9 +305,10 @@ function initialize_block!(ax::Axis; palette = nothing) end end - yoppositelinepoints = lift( - blockscene, scene.viewport, ax.spinewidth, ax.yaxisposition; - ignore_equal_values = true + map!( + ax.attributes, + [:viewport, :spinewidth, :yaxisposition], + :yoppositelinepoints ) do r, sw, yaxpos if yaxpos === :right x = left(r) @@ -411,89 +323,149 @@ function initialize_block!(ax::Axis; palette = nothing) end end - xticksmirrored = lift( - mirror_ticks, blockscene, xaxis.tickpositions, ax.xticksize, ax.xtickalign, - scene.viewport, :x, ax.xaxisposition[], ax.spinewidth + map!( + mirror_xticks, ax.attributes, + [(:xaxis, :tickpositions), :xticksize, :xtickalign, :viewport, :xaxisposition, :spinewidth], + :xticksmirrored_points ) + map!((a, b) -> a && b, ax.attributes, [:xticksmirrored, :xticksvisible], :mirroredxticksvisible) xticksmirrored_lines = linesegments!( - blockscene, xticksmirrored, visible = @lift($(ax.xticksmirrored) && $(ax.xticksvisible)), + blockscene, ax.xticksmirrored_points, visible = ax.mirroredxticksvisible, linewidth = ax.xtickwidth, color = ax.xtickcolor ) translate!(xticksmirrored_lines, 0, 0, 10) - yticksmirrored = lift( - mirror_ticks, blockscene, yaxis.tickpositions, ax.yticksize, ax.ytickalign, - scene.viewport, :y, ax.yaxisposition[], ax.spinewidth + + map!( + mirror_yticks, ax.attributes, + [(:yaxis, :tickpositions), :yticksize, :ytickalign, :viewport, :yaxisposition, :spinewidth], + :yticksmirrored_points ) + map!((a, b) -> a && b, ax.attributes, [:yticksmirrored, :yticksvisible], :mirroredyticksvisible) yticksmirrored_lines = linesegments!( - blockscene, yticksmirrored, visible = @lift($(ax.yticksmirrored) && $(ax.yticksvisible)), + blockscene, ax.yticksmirrored_points, visible = ax.mirroredyticksvisible, linewidth = ax.ytickwidth, color = ax.ytickcolor ) translate!(yticksmirrored_lines, 0, 0, 10) - xminorticksmirrored = lift( - mirror_ticks, blockscene, xaxis.minortickpositions, ax.xminorticksize, - ax.xminortickalign, scene.viewport, :x, ax.xaxisposition[], ax.spinewidth + + map!( + mirror_xticks, ax.attributes, + [(:xaxis, :minortickpositions), :xminorticksize, :xminortickalign, :viewport, :xaxisposition, :spinewidth], + :xminorticksmirrored ) + map!((a, b) -> a && b, ax.attributes, [:xticksmirrored, :xminorticksvisible], :mirroredxminorticksvisible) xminorticksmirrored_lines = linesegments!( - blockscene, xminorticksmirrored, visible = @lift($(ax.xticksmirrored) && $(ax.xminorticksvisible)), + blockscene, ax.xminorticksmirrored, visible = ax.mirroredxminorticksvisible, linewidth = ax.xminortickwidth, color = ax.xminortickcolor ) translate!(xminorticksmirrored_lines, 0, 0, 10) - yminorticksmirrored = lift( - mirror_ticks, blockscene, yaxis.minortickpositions, ax.yminorticksize, - ax.yminortickalign, scene.viewport, :y, ax.yaxisposition[], ax.spinewidth + + map!( + mirror_yticks, ax.attributes, + [(:yaxis, :minortickpositions), :yminorticksize, :yminortickalign, :viewport, :yaxisposition, :spinewidth], + :yminorticksmirrored ) + map!((a, b) -> a && b, ax.attributes, [:yticksmirrored, :yminorticksvisible], :mirroredyminorticksvisible) yminorticksmirrored_lines = linesegments!( - blockscene, yminorticksmirrored, visible = @lift($(ax.yticksmirrored) && $(ax.yminorticksvisible)), + blockscene, ax.yminorticksmirrored, visible = ax.mirroredyminorticksvisible, linewidth = ax.yminortickwidth, color = ax.yminortickcolor ) translate!(yminorticksmirrored_lines, 0, 0, 10) xoppositeline = linesegments!( - blockscene, xoppositelinepoints, linewidth = ax.spinewidth, - visible = xoppositespinevisible, color = xoppositespinecolor, inspectable = false, + blockscene, ax.xoppositelinepoints, linewidth = ax.spinewidth, + visible = ax.xoppositespinevisible, color = ax.xoppositespinecolor, + inspectable = false, linestyle = nothing ) elements[:xoppositeline] = xoppositeline translate!(xoppositeline, 0, 0, 20) yoppositeline = linesegments!( - blockscene, yoppositelinepoints, linewidth = ax.spinewidth, - visible = yoppositespinevisible, color = yoppositespinecolor, inspectable = false, + blockscene, ax.yoppositelinepoints, linewidth = ax.spinewidth, + visible = ax.yoppositespinevisible, color = ax.yoppositespinecolor, + inspectable = false, linestyle = nothing ) elements[:yoppositeline] = yoppositeline translate!(yoppositeline, 0, 0, 20) - onany(blockscene, xaxis.tickpositions, scene.viewport) do tickpos, area + map!( + ax.attributes, + [(:xaxis, :tickpositions), :xaxisposition, :viewport], + :xgrid_points + ) do tickpos, axispos, area local pxheight::Float32 = height(area) - local offset::Float32 = ax.xaxisposition[] === :bottom ? pxheight : -pxheight - update_gridlines!(xgridnode, Point2f(0, offset), tickpos) + local offset::Float32 = axispos === :bottom ? pxheight : -pxheight + return gridline_points(Point2f(0, offset), tickpos) end - onany(blockscene, yaxis.tickpositions, scene.viewport) do tickpos, area + map!( + ax.attributes, + [(:yaxis, :tickpositions), :yaxisposition, :viewport], + :ygrid_points + ) do tickpos, axispos, area local pxwidth::Float32 = width(area) - local offset::Float32 = ax.yaxisposition[] === :left ? pxwidth : -pxwidth - update_gridlines!(ygridnode, Point2f(offset, 0), tickpos) + local offset::Float32 = axispos === :left ? pxwidth : -pxwidth + return gridline_points(Point2f(offset, 0), tickpos) end - onany(blockscene, xaxis.minortickpositions, scene.viewport) do tickpos, area - local pxheight::Float32 = height(scene.viewport[]) - local offset::Float32 = ax.xaxisposition[] === :bottom ? pxheight : -pxheight - update_gridlines!(xminorgridnode, Point2f(0, offset), tickpos) + map!( + ax.attributes, + [(:xaxis, :minortickpositions), :xaxisposition, :viewport], + :xminorgrid_points + ) do tickpos, axispos, area + local pxheight::Float32 = height(area) + local offset::Float32 = axispos === :bottom ? pxheight : -pxheight + return gridline_points(Point2f(0, offset), tickpos) end - onany(blockscene, yaxis.minortickpositions, scene.viewport) do tickpos, area - local pxwidth::Float32 = width(scene.viewport[]) - local offset::Float32 = ax.yaxisposition[] === :left ? pxwidth : -pxwidth - update_gridlines!(yminorgridnode, Point2f(offset, 0), tickpos) + map!( + ax.attributes, + [(:yaxis, :minortickpositions), :yaxisposition, :viewport], + :yminorgrid_points + ) do tickpos, axispos, area + local pxwidth::Float32 = width(area) + local offset::Float32 = axispos === :left ? pxwidth : -pxwidth + return gridline_points(Point2f(offset, 0), tickpos) end - subtitlepos = lift( - blockscene, scene.viewport, ax.titlegap, ax.titlealign, ax.xaxisposition, - xaxis.protrusion; - ignore_equal_values = true - ) do a, - titlegap, align, xaxisposition, xaxisprotrusion + xgridlines = linesegments!( + blockscene, ax.xgrid_points, linewidth = ax.xgridwidth, visible = ax.xgridvisible, + color = ax.xgridcolor, linestyle = ax.xgridstyle, inspectable = false + ) + # put gridlines behind the zero plane so they don't overlay plots + translate!(xgridlines, 0, 0, -10) + elements[:xgridlines] = xgridlines + + xminorgridlines = linesegments!( + blockscene, ax.xminorgrid_points, linewidth = ax.xminorgridwidth, visible = ax.xminorgridvisible, + color = ax.xminorgridcolor, linestyle = ax.xminorgridstyle, inspectable = false + ) + # put gridlines behind the zero plane so they don't overlay plots + translate!(xminorgridlines, 0, 0, -10) + elements[:xminorgridlines] = xminorgridlines + + ygridlines = linesegments!( + blockscene, ax.ygrid_points, linewidth = ax.ygridwidth, visible = ax.ygridvisible, + color = ax.ygridcolor, linestyle = ax.ygridstyle, inspectable = false + ) + # put gridlines behind the zero plane so they don't overlay plots + translate!(ygridlines, 0, 0, -10) + elements[:ygridlines] = ygridlines + + yminorgridlines = linesegments!( + blockscene, ax.yminorgrid_points, linewidth = ax.yminorgridwidth, visible = ax.yminorgridvisible, + color = ax.yminorgridcolor, linestyle = ax.yminorgridstyle, inspectable = false + ) + # put gridlines behind the zero plane so they don't overlay plots + translate!(yminorgridlines, 0, 0, -10) + elements[:yminorgridlines] = yminorgridlines + + map!( + ax.attributes, + [:viewport, :titlegap, :titlealign, :xaxisposition, (:xaxis, :protrusion)], + :subtitlepos + ) do a, titlegap, align, xaxisposition, xaxisprotrusion align_factor = halign2num(align, "Horizontal title align $align not supported.") x = a.origin[1] + align_factor * a.widths[1] @@ -503,16 +475,15 @@ function initialize_block!(ax::Axis; palette = nothing) return Point2f(x, yoffset) end - titlealignnode = lift(blockscene, ax.titlealign; ignore_equal_values = true) do align - (align, :bottom) - end + map!(align -> (align, :bottom), ax.attributes, :titlealign, :titlealign_tuple) subtitlet = text!( - blockscene, subtitlepos, + blockscene, + ax.subtitlepos, text = ax.subtitle, visible = ax.subtitlevisible, fontsize = ax.subtitlesize, - align = titlealignnode, + align = ax.titlealign_tuple, font = ax.subtitlefont, color = ax.subtitlecolor, lineheight = ax.subtitlelineheight, @@ -520,17 +491,27 @@ function initialize_block!(ax::Axis; palette = nothing) inspectable = false ) - titlepos = lift( - calculate_title_position, blockscene, scene.viewport, ax.titlegap, ax.subtitlegap, - ax.titlealign, ax.xaxisposition, xaxis.protrusion, ax.subtitlelineheight, ax, subtitlet; ignore_equal_values = true + subtitle_bbox = register_raw_string_boundingboxes!(subtitlet) + map!( + ax.attributes, [subtitle_bbox, :subtitlevisible, :subtitlegap], :subtitle_height + ) do bboxes, visible, gap + bb = reduce(update_boundingbox, bboxes, init = Rect3f()) + height = widths(bb)[2] + return isfinite(height) && visible ? Float32(height + gap) : 0.0f0 + end + + map!( + calculate_title_position, ax.attributes, + [:viewport, :titlegap, :titlealign, :xaxisposition, (:xaxis, :protrusion), :subtitle_height], + :titlepos ) titlet = text!( - blockscene, titlepos, + blockscene, ax.titlepos, text = ax.title, visible = ax.titlevisible, fontsize = ax.titlesize, - align = titlealignnode, + align = ax.titlealign_tuple, font = ax.titlefont, color = ax.titlecolor, lineheight = ax.titlelineheight, @@ -539,16 +520,26 @@ function initialize_block!(ax::Axis; palette = nothing) ) elements[:title] = titlet + title_bbox = register_raw_string_boundingboxes!(titlet) + map!( + ax.attributes, [title_bbox, :titlevisible, :titlegap], :title_height + ) do bboxes, visible, gap + bb = reduce(update_boundingbox, bboxes, init = Rect3f()) + height = widths(bb)[2] + return isfinite(height) && visible ? Float32(height + gap) : 0.0f0 + end + map!( - compute_protrusions, blockscene, ax.layoutobservables.protrusions, ax.title, ax.titlesize, - ax.titlegap, ax.titlevisible, ax.spinewidth, - ax.topspinevisible, ax.bottomspinevisible, ax.leftspinevisible, ax.rightspinevisible, - xaxis.protrusion, yaxis.protrusion, ax.xaxisposition, ax.yaxisposition, - ax.subtitle, ax.subtitlevisible, ax.subtitlesize, ax.subtitlegap, - ax.titlelineheight, ax.subtitlelineheight, subtitlet, titlet + compute_protrusions, ax.attributes, + [ + :title_height, :subtitle_height, + :xaxisposition, (:xaxis, :protrusion), + :yaxisposition, (:yaxis, :protrusion), + ], + :layout_protrusions ) - # trigger first protrusions with one of the observables - # notify(ax.title) + + connect!(ax.layoutobservables.protrusions, ax.layout_protrusions) # trigger bboxnode so the axis layouts itself even if not connected to a # layout @@ -581,9 +572,9 @@ function initialize_block!(ax::Axis; palette = nothing) notify(finallimits) end - # Needed to fully initialize layouting for some reason... - notify(ComputePipeline.get_observable!(ax.xlabelpadding)) - notify(ComputePipeline.get_observable!(ax.ylabelpadding)) + # # Needed to fully initialize layouting for some reason... + # notify(ComputePipeline.get_observable!(ax.xlabelpadding)) + # notify(ComputePipeline.get_observable!(ax.ylabelpadding)) # Add them last, so we skip all the internal iterations from above! add_input!(ax.scene.compute, :axis_limits, finallimits) @@ -602,6 +593,8 @@ function add_axis_limits!(plot) return end +mirror_xticks(tp, ts, ta, vp, ap, sw) = mirror_ticks(tp, ts, ta, vp, :x, ap, sw) +mirror_yticks(tp, ts, ta, vp, ap, sw) = mirror_ticks(tp, ts, ta, vp, :y, ap, sw) function mirror_ticks(tickpositions, ticksize, tickalign, viewport, side, axisposition, spinewidth) a = viewport if side === :x @@ -1081,8 +1074,8 @@ function timed_ticklabelspace_reset( prev_xticklabelspace[] = ax.xticklabelspace[] prev_yticklabelspace[] = ax.yticklabelspace[] - ax.xticklabelspace = Float64(ax.xaxis.attributes.actual_ticklabelspace[]) - ax.yticklabelspace = Float64(ax.yaxis.attributes.actual_ticklabelspace[]) + ax.xticklabelspace = Float64(ax.attributes.xaxis.actual_ticklabelspace[]) + ax.yticklabelspace = Float64(ax.attributes.yaxis.actual_ticklabelspace[]) end return reset_timer[] = Timer(threshold_sec) do t diff --git a/Makie/src/makielayout/blocks/colorbar.jl b/Makie/src/makielayout/blocks/colorbar.jl index 5729abdfc87..8cc2fd33c4f 100644 --- a/Makie/src/makielayout/blocks/colorbar.jl +++ b/Makie/src/makielayout/blocks/colorbar.jl @@ -411,7 +411,8 @@ function initialize_block!(cb::Colorbar) end axis = LineAxis( - blockscene, endpoints = axispoints, flipped = cb.flipaxis, + blockscene, ComputePipeline.ComputeGraphView(cb.attributes, :axis), + endpoints = axispoints, flipped = cb.flipaxis, limits = lims, ticklabelalign = cb.ticklabelalign, label = cb.label, labelpadding = cb.labelpadding, labelvisible = cb.labelvisible, labelsize = cb.labelsize, labelcolor = cb.labelcolor, labelrotation = cb.labelrotation, @@ -423,7 +424,7 @@ function initialize_block!(cb::Colorbar) ticklabelrotation = cb.ticklabelrotation, tickwidth = cb.tickwidth, tickcolor = cb.tickcolor, spinewidth = cb.spinewidth, ticklabelspace = cb.ticklabelspace, ticklabelcolor = cb.ticklabelcolor, - spinecolor = :transparent, spinevisible = :false, flip_vertical_label = cb.flip_vertical_label, + spinecolor = :transparent, spinevisible = false, flip_vertical_label = cb.flip_vertical_label, minorticksvisible = cb.minorticksvisible, minortickalign = cb.minortickalign, minorticksize = cb.minorticksize, minortickwidth = cb.minortickwidth, minortickcolor = cb.minortickcolor, minorticks = cb.minorticks, scale = cmap.scale diff --git a/Makie/src/makielayout/defaultattributes.jl b/Makie/src/makielayout/defaultattributes.jl index f64cc3c82c3..1f8963ec066 100644 --- a/Makie/src/makielayout/defaultattributes.jl +++ b/Makie/src/makielayout/defaultattributes.jl @@ -68,7 +68,12 @@ function generic_plot_attributes(::Type{LineAxis}) minortickwidth = 1.0f0, minortickcolor = :black, minorticks = Makie.automatic, + minorticksused = false, scale = identity, + unit_in_ticklabel = true, + suffix_formatter = "", + unit_in_label = false, + use_short_unit = true, ) end diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 473cdbaa2da..86c5006d054 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -3,7 +3,7 @@ # looks more balanced with numbers, especially in superscripts or subscripts const MINUS_SIGN = "−" # == "\u2212" (Unicode minus) -function LineAxis(parent::Scene; @nospecialize(kwargs...)) +function LineAxis(parent::Scene, graph::AbstractComputeGraph; @nospecialize(kwargs...)) attrs = mergeleft!(Attributes(kwargs), generic_plot_attributes(LineAxis)) # Attributes() maps all typed observables to Observable{Any}. This means @@ -17,7 +17,7 @@ function LineAxis(parent::Scene; @nospecialize(kwargs...)) attrs[:ticklabelspace] = ComputePipeline.get_observable!(attrs[:ticklabelspace]) end - return LineAxis(parent, attrs) + return LineAxis(parent, graph, attrs) end function calculate_horizontal_extends(endpoints)::Tuple{Float32, NTuple{2, Float32}, Bool} @@ -38,13 +38,12 @@ end function calculate_protrusion( - closure_args, - ticksvisible::Bool, label, labelvisible::Bool, labelpadding::Number, tickspace::Number, ticklabelsvisible::Bool, + horizontal, labeltext, ticklabel_position, + ticksvisible::Bool, label, labelvisible::Bool, labelpadding::Number, + tickspace::Number, ticklabelsvisible::Bool, actual_ticklabelspace::Number, ticklabelpad::Number, _... ) - horizontal, labeltext, ticklabel_annotation_obs = closure_args - label_is_empty::Bool = iswhitespace(label) real_labelsize::Float32 = if label_is_empty @@ -58,21 +57,21 @@ function calculate_protrusion( labelspace::Float32 = (labelvisible && !label_is_empty) ? real_labelsize + labelpadding : 0.0f0 - _tickspace::Float32 = (ticksvisible && !isempty(ticklabel_annotation_obs[])) ? tickspace : 0.0f0 + _tickspace::Float32 = (ticksvisible && !isempty(ticklabel_position[])) ? tickspace : 0.0f0 - ticklabelgap::Float32 = (ticklabelsvisible && actual_ticklabelspace > 0) ? actual_ticklabelspace + ticklabelpad : 0.0f0 + needs_gap = (ticklabelsvisible && actual_ticklabelspace > 0) + ticklabelgap::Float32 = needs_gap ? actual_ticklabelspace + ticklabelpad : 0.0f0 return _tickspace + ticklabelgap + labelspace end function create_linepoints( - pos_ext_hor, - flipped::Bool, spine_width::Number, trimspine::Union{Bool, Tuple{Bool, Bool}}, tickpositions::Vector{Point2f}, tickwidth::Number + position::Float32, extents::NTuple{2, Float32}, horizontal::Bool, + flipped::Bool, spine_width::Number, trimspine::Union{Bool, Tuple{Bool, Bool}}, + tickpositions::Vector{Point2f}, tickwidth::Number ) - (position::Float32, extents::NTuple{2, Float32}, horizontal::Bool) = pos_ext_hor - if trimspine isa Bool trimspine = (trimspine, trimspine) end @@ -112,7 +111,7 @@ end function calculate_real_ticklabel_align(al, horizontal, fl::Bool, rot::Number) hor = horizontal[]::Bool - if al isa Automatic + return if al isa Automatic if rot == 0 || !(rot isa Real) if hor (:center, fl ? :bottom : :top) @@ -145,7 +144,7 @@ function calculate_real_ticklabel_align(al, horizontal, fl::Bool, rot::Number) end end elseif al isa NTuple{2, Symbol} - return al + al else error("Align needs to be a NTuple{2, Symbol}.") end @@ -153,45 +152,25 @@ end max_auto_ticklabel_spacing!(ax) = nothing - -function update_ticklabel_node( - closure_args, - ticklabel_annotation_obs::Observable, - labelgap::Number, flipped::Bool, tickpositions::Vector{Point2f}, tickstrings +function adjust_ticklabel_placement( + tickpositions, horizontal, flipped, + spinewidth, tickspace, ticklabelpad ) - # tickspace is always updated before labelgap - # tickpositions are always updated before tickstrings - # so we don't need to lift those - - horizontal, spinewidth, tickspace, ticklabelpad, tickvalues = closure_args + ticklabelgap = spinewidth + tickspace + ticklabelpad - nticks = length(tickvalues[]) - - ticklabelgap::Float32 = spinewidth[] + tickspace[] + ticklabelpad[] - - shift = if horizontal[] + shift = if horizontal Point2f(0.0f0, flipped ? ticklabelgap : -ticklabelgap) else Point2f(flipped ? ticklabelgap : -ticklabelgap, 0.0f0) end # reuse already allocated array - result = ticklabel_annotation_obs[] - empty!(result) - for i in 1:min(length(tickstrings), length(tickpositions)) - pos = tickpositions[i] - str = tickstrings[i] - push!(result, (str, pos .+ shift)) - end - # notify of the changes - notify(ticklabel_annotation_obs) - return + return Point2f[pos .+ shift for pos in tickpositions] end -function update_tick_obs(tick_obs, horizontal::Observable{Bool}, flipped::Observable{Bool}, tickpositions, tickalign, ticksize, spinewidth) - result = tick_obs[] - empty!(result) # reuse allocated array - sign::Int = flipped[] ? -1 : 1 - if horizontal[] +function calculated_aligned_ticks(horizontal, flipped, tickpositions, tickalign, ticksize, spinewidth) + result = Point2f[] + sign = ifelse(flipped, -1, 1) + if horizontal for tp in tickpositions tstart = tp + sign * Point2f(0.0f0, tickalign * ticksize - 0.5f0 * spinewidth) tend = tstart + sign * Point2f(0.0f0, -ticksize) @@ -204,56 +183,17 @@ function update_tick_obs(tick_obs, horizontal::Observable{Bool}, flipped::Observ push!(result, tstart, tend) end end - notify(tick_obs) - return + return result end # if labels are given manually, it's possible that some of them are outside the displayed limits # we only check approximately because we want to keep ticks on the frame is_within_limits(tv, limits) = (limits[1] - 100eps(limits[1]) < tv) && (tv < limits[2] + 100eps(limits[2])) -function update_tickpos_string(closure_args, tickvalues_labels_unfiltered, reversed::Bool, scale) - - tickstrings, tickpositions, tickvalues, pos_extents_horizontal, limits_obs = closure_args - limits = limits_obs[]::NTuple{2, Float64} - - tickvalues_unfiltered, tickstrings_unfiltered = tickvalues_labels_unfiltered - - position::Float32, extents_uncorrected::NTuple{2, Float32}, horizontal::Bool = pos_extents_horizontal[] - - extents = reversed ? reverse(extents_uncorrected) : extents_uncorrected - - px_o = extents[1] - px_width = extents[2] - extents[1] - - lim_o = limits[1] - lim_w = limits[2] - limits[1] - - i_values_within_limits = findall(tv -> is_within_limits(tv, limits), tickvalues_unfiltered) - - tickvalues[] = tickvalues_unfiltered[i_values_within_limits] - - tickvalues_scaled = scale.(tickvalues[]) - - tick_fractions = (tickvalues_scaled .- scale(limits[1])) ./ (scale(limits[2]) - scale(limits[1])) - - tick_scenecoords = px_o .+ px_width .* tick_fractions - - tickpos = if horizontal - [Point2f(x, position) for x in tick_scenecoords] - else - [Point2f(position, y) for y in tick_scenecoords] - end - - # now trigger updates - tickpositions[] = tickpos - tickstrings[] = tickstrings_unfiltered[i_values_within_limits] - return -end - -function update_minor_ticks(minortickpositions, limits::NTuple{2, Float64}, pos_extents_horizontal, minortickvalues_unfiltered, scale, reversed::Bool) - position::Float32, extents_uncorrected::NTuple{2, Float32}, horizontal::Bool = pos_extents_horizontal - +function compute_minor_ticks( + limits, position, extents_uncorrected, horizontal, minortickvalues_unfiltered, + scale, reversed::Bool + ) extents = reversed ? reverse(extents_uncorrected) : extents_uncorrected px_o = extents[1] @@ -267,13 +207,11 @@ function update_minor_ticks(minortickpositions, limits::NTuple{2, Float64}, pos_ tick_scenecoords = px_o .+ px_width .* tick_fractions - minortickpositions[] = if horizontal - [Point2f(x, position) for x in tick_scenecoords] + if horizontal + return [Point2f(x, position) for x in tick_scenecoords] else - [Point2f(position, y) for y in tick_scenecoords] + return [Point2f(position, y) for y in tick_scenecoords] end - - return end function build_label_with_unit_suffix(dim_convert, formatter, label, show_unit_in_label, use_short_units) @@ -286,108 +224,130 @@ function build_label_with_unit_suffix(dim_convert, formatter, label, show_unit_i end end -function LineAxis(parent::Scene, attrs::Attributes) - decorations = Dict{Symbol, Any}() +macro make_computed(graph, key) + return quote + if !haskey($(esc(graph)), $(QuoteNode(key))) + # if !isa($(esc(key)), Computed) + # println("Added: ", $(QuoteNode(key)), "::", typeof($(esc(key)))) + # end + add_input!($(esc(graph)), $(QuoteNode(key)), $(esc(key))) + end + end +end - @extract attrs ( - endpoints, ticksize, tickwidth, - tickcolor, tickalign, dim_convert, ticks, tickformat, ticklabelalign, ticklabelrotation, ticksvisible, - ticklabelspace, ticklabelpad, labelpadding, - ticklabelsize, ticklabelsvisible, spinewidth, spinecolor, label, labelsize, labelcolor, - labelfont, ticklabelfont, ticklabelcolor, - labelrotation, labelvisible, spinevisible, trimspine, flip_vertical_label, reversed, - minorticksvisible, minortickalign, minorticksize, minortickwidth, minortickcolor, minorticks, - ) - minorticksused = get(attrs, :minorticksused, Observable(false)) +function _extract_computed(graph::ComputePipeline.AbstractComputeGraph, dictlike, name) + entry = dictlike[name] + root = ComputePipeline.root(graph) + if (entry isa ComputePipeline.Computed) && (entry.parent.graph == root) + return entry + elseif entry isa Union{Attributes, ComputePipeline.AbstractComputeGraph} + error("$name::$(typeof(entry)) is not supported in @extract_computed") + else + # to_recipe_attribute does Ref{Any} wrapping (in case types can change) + add_input!(to_recipe_attribute, graph, name, entry) + return graph[name] + end +end - pos_extents_horizontal = lift(calculate_horizontal_extends, parent, endpoints; ignore_equal_values = true) - horizontal = lift(x -> x[3], parent, pos_extents_horizontal) - # Tuple constructor converts more than `convert(NTuple{2, Float32}, x)` but we still need the conversion to Float32 tuple: - limits = lift(x -> convert(NTuple{2, Float64}, Tuple(x)), parent, attrs.limits; ignore_equal_values = true) - flipped = lift(x -> convert(Bool, x), parent, attrs.flipped; ignore_equal_values = true) +""" + @extract_computed source graph (name1, name2, ...) + +Extracts entries with the given names from `source` and makes them available as +variables with the same name. If the entry is a compute node from (the root +parent of) `graph` it will be written to the variable directly with +`name1 = source[:name1]`. Otherwise it will be added to `graph` with +`add_input!(graph, :name1, source[:name1])` and the added node will be used +instead with `name1 = graph[:name1]`. + +Note that this does not imply that `graph[:name1]` exists. It implies that +`:name1` exists somewhere in the root parent of graph, which might be a +(different) nested sub graph from `graph`. To be safe, pass `name1` instead of +`:name1` to computations when using this macro. +""" +macro extract_computed(attrs, graph, names) + define_func = quote + extract_computed(dictlike, name) = _extract_computed($(esc(graph)), dictlike, name) + end + expr = extract_expr(:extract_computed, attrs, names) + pushfirst!(expr.args, define_func) + return expr +end - ticksnode = Observable(Point2f[]; ignore_equal_values = true) - ticklines = linesegments!( - parent, ticksnode, linewidth = tickwidth, color = tickcolor, linestyle = nothing, - visible = ticksvisible, inspectable = false - ) - decorations[:ticklines] = ticklines - translate!(ticklines, 0, 0, 10) +function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) + decorations = Dict{Symbol, Any}() - minorticksnode = Observable(Point2f[]; ignore_equal_values = true) - minorticklines = linesegments!( - parent, minorticksnode, linewidth = minortickwidth, color = minortickcolor, - linestyle = nothing, visible = minorticksvisible, inspectable = false + @extract_computed attrs graph ( + endpoints, limits, flipped, scale, dim_convert, + ticksize, tickwidth, tickcolor, tickalign, ticks, tickformat, ticksvisible, + ticklabelalign, ticklabelrotation, ticklabelspace, ticklabelpad, + ticklabelsize, ticklabelsvisible, ticklabelfont, ticklabelcolor, + spinewidth, spinecolor, spinevisible, + label, labelsize, labelcolor, labelpadding, labelfont, labelrotation, labelvisible, + trimspine, flip_vertical_label, reversed, + minorticksvisible, minortickalign, minorticksize, minortickwidth, minortickcolor, + minorticks, minorticksused, + unit_in_ticklabel, suffix_formatter, unit_in_label, use_short_unit, ) - decorations[:minorticklines] = minorticklines - translate!(minorticklines, 0, 0, 10) - realticklabelalign = Observable{Tuple{Symbol, Symbol}}((:none, :none); ignore_equal_values = true) + map!(calculate_horizontal_extends, graph, endpoints, [:position, :extents, :horizontal]) + + # TODO: Does this have side effects on Axis, plots? + # TODO: Does this propagate enough on same value updates? + # make sure we update tick calculation when needed + obs = needs_tick_update_observable(dim_convert) + on(x -> ComputePipeline.mark_dirty!(dim_convert), obs) map!( - calculate_real_ticklabel_align, parent, realticklabelalign, ticklabelalign, horizontal, flipped, - ticklabelrotation + calculate_real_ticklabel_align, graph, + [ticklabelalign, :horizontal, flipped, ticklabelrotation], + :realticklabelalign ) - ticklabel_annotation_obs = Observable(Tuple{Any, Point2f}[]; ignore_equal_values = true) - ticklabels_ref = Ref{Any}(nothing) # this gets overwritten later to be used in the below - ticklabel_ideal_space = Observable(0.0f0; ignore_equal_values = true) + add_input!((k, r) -> Rect2f(r), graph, :ticklabelbbox, Rect3d()) - map!(parent, ticklabel_ideal_space, ticklabel_annotation_obs, ticklabelalign, ticklabelrotation, ticklabelfont, ticklabelsvisible) do args... - maxwidth = if pos_extents_horizontal[][3] - # height - ticklabelsvisible[] ? (ticklabels_ref[] === nothing ? 0.0f0 : height(Rect2f(boundingbox(ticklabels_ref[], :data)))) : 0.0f0 - else - # width - ticklabelsvisible[] ? (ticklabels_ref[] === nothing ? 0.0f0 : width(Rect2f(boundingbox(ticklabels_ref[], :data)))) : 0.0f0 - end - # in case there is no string in the annotations and the boundingbox comes back all NaN - if !isfinite(maxwidth) - maxwidth = zero(maxwidth) - end - return maxwidth + map!(graph, [:horizontal, :ticklabelbbox], :ticklabel_ideal_space) do horizontal, bbox + maxwidth = horizontal ? height(bbox) : width(bbox) + # not finite until the plot is created + # Note: This used to be `isfinite(maxwidth) && visible` - probably not needed? + return isfinite(maxwidth) ? maxwidth : zero(maxwidth) end - attrs[:actual_ticklabelspace] = 0.0f0 - actual_ticklabelspace = attrs[:actual_ticklabelspace] - - onany(parent, ticklabel_ideal_space, ticklabelspace, update = true) do idealspace, space - s = if space == automatic - idealspace + register_computation!( + graph, + [:ticklabel_ideal_space, ticklabelspace], + [:actual_ticklabelspace] + ) do (idealspace, space), changed, cached + actual_ticklabelspace = isnothing(cached) ? 0.0f0 : cached[1] + if space == automatic + return (idealspace,) elseif space isa Symbol space === :max_auto || error("Invalid ticklabel space $(repr(space)), may be automatic, :max_auto or a real number") - max(idealspace, actual_ticklabelspace[]) + return (max(idealspace, actual_ticklabelspace),) else - space - end - if s != actual_ticklabelspace[] - actual_ticklabelspace[] = s + return (space,) end end - tickspace = Observable(0.0f0; ignore_equal_values = true) - map!(parent, tickspace, ticksvisible, ticksize, tickalign) do ticksvisible, ticksize, tickalign - ticksvisible ? max(0.0f0, ticksize * (1.0f0 - tickalign)) : 0.0f0 + map!(graph, [ticksvisible, ticksize, tickalign], :tickspace) do ticksvisible, ticksize, tickalign + return ticksvisible ? max(0.0f0, ticksize * (1.0f0 - tickalign)) : 0.0f0 end - labelgap = Observable(0.0f0; ignore_equal_values = true) map!( - parent, labelgap, spinewidth, tickspace, ticklabelsvisible, actual_ticklabelspace, - ticklabelpad, labelpadding - ) do spinewidth, tickspace, ticklabelsvisible, - actual_ticklabelspace, ticklabelpad, labelpadding + graph, + [spinewidth, :tickspace, ticklabelsvisible, :actual_ticklabelspace, ticklabelpad, labelpadding], + :labelgap + ) do spinewidth, tickspace, ticklabelsvisible, actual_ticklabelspace, ticklabelpad, labelpadding return spinewidth + tickspace + (ticklabelsvisible ? actual_ticklabelspace + ticklabelpad : 0.0f0) + labelpadding end - labelpos = Observable(Point2f(NaN); ignore_equal_values = true) - map!( - parent, labelpos, pos_extents_horizontal, flipped, - labelgap - ) do (position, extents, horizontal), flipped, labelgap + graph, + [:position, :extents, :horizontal, flipped, :labelgap], + :labelpos + ) do position, extents, horizontal, flipped, labelgap # fullgap = tickspace[] + labelgap middle = extents[1] + 0.5f0 * (extents[2] - extents[1]) @@ -396,13 +356,11 @@ function LineAxis(parent::Scene, attrs::Attributes) return horizontal ? Point2f(middle, x_or_y) : Point2f(x_or_y, middle) end - # Initial values should be overwritten by map!. `ignore_equal_values` doesn't work right now without initial values - labelalign = Observable((:none, :none); ignore_equal_values = true) map!( - parent, labelalign, labelrotation, horizontal, flipped, - flip_vertical_label - ) do labelrotation, - horizontal::Bool, flipped::Bool, flip_vertical_label::Bool + graph, + [labelrotation, :horizontal, flipped, flip_vertical_label], + :labelalign + ) do labelrotation, horizontal::Bool, flipped::Bool, flip_vertical_label::Bool return if labelrotation isa Automatic if horizontal (:center, flipped ? :bottom : :top) @@ -420,12 +378,12 @@ function LineAxis(parent::Scene, attrs::Attributes) end::NTuple{2, Symbol} end - labelrot = Observable(0.0f0; ignore_equal_values = true) + map!( - parent, labelrot, labelrotation, horizontal, - flip_vertical_label - ) do labelrotation, - horizontal::Bool, flip_vertical_label::Bool + graph, + [labelrotation, :horizontal, flip_vertical_label], + :labelrot + ) do labelrotation, horizontal::Bool, flip_vertical_label::Bool return if labelrotation isa Automatic if horizontal 0.0f0 @@ -437,30 +395,25 @@ function LineAxis(parent::Scene, attrs::Attributes) end::Float32 end - # label + dim convert suffix - # TODO probably make these mandatory - suffix_formatter = get(attrs, :label_suffix, Observable("")) - unit_in_label = get(attrs, :unit_in_label, Observable(false)) - use_short_unit = get(attrs, :use_short_unit, Observable(true)) - obs = needs_tick_update_observable(dim_convert) # make sure we update tick calculation when needed - label_with_suffix = Observable{Any}() + # label + dim convert suffix map!( - label_with_suffix, label, suffix_formatter, unit_in_label, use_short_unit, obs, update = true - ) do label, formatter, show_unit_in_label, use_short_unit, _ - return build_label_with_unit_suffix(dim_convert[], formatter, label, show_unit_in_label, use_short_unit) - end + build_label_with_unit_suffix, graph, + [dim_convert, suffix_formatter, label, unit_in_label, use_short_unit], + :label_with_suffix + ) labeltext = text!( - parent, labelpos, text = label_with_suffix, fontsize = labelsize, color = labelcolor, + parent, graph.labelpos, text = graph.label_with_suffix, + fontsize = labelsize, color = labelcolor, visible = labelvisible, - align = labelalign, rotation = labelrot, font = labelfont, + align = graph.labelalign, rotation = graph.labelrot, font = labelfont, markerspace = :data, inspectable = false ) # translate axis labels on explicit rotations # in order to prevent plot and axis overlap - onany(parent, labelrotation, flipped, horizontal) do labelrotation, flipped, horizontal + onany(parent, labelrotation, flipped, graph.horizontal) do labelrotation, flipped, horizontal xs::Float32, ys::Float32 = if labelrotation isa Automatic 0.0f0, 0.0f0 else @@ -479,98 +432,131 @@ function LineAxis(parent::Scene, attrs::Attributes) decorations[:labeltext] = labeltext - tickvalues = Observable(Float64[]; ignore_equal_values = true) - unit_in_ticklabel = get(attrs, :unit_in_ticklabel, Observable(true)) + map!( + graph, + # TODO: Why was :pos_extents_horizontal in here? + [dim_convert, limits, ticks, tickformat, scale, unit_in_ticklabel], + [:tickvalues_unfiltered, :tickstrings_unfiltered], + ) do dim_convert, limits, ticks, tickformat, scale, unit_in_ticklabel + should_show = show_dim_convert_in_ticklabel(dim_convert, unit_in_ticklabel) + vals, strs = get_ticks(dim_convert, ticks, scale, tickformat, limits..., should_show) + return vals, convert(Vector{Any}, strs) + end - tickvalues_labels_unfiltered = Observable{Tuple{Vector{Float64}, Vector{Any}}}() map!( - parent, tickvalues_labels_unfiltered, pos_extents_horizontal, obs, limits, ticks, tickformat, - attrs.scale, unit_in_ticklabel - ) do (position, extents, horizontal), _, limits, ticks, tickformat, scale, show_option - dc = dim_convert[] - should_show = show_dim_convert_in_ticklabel(dc, show_option) - return get_ticks(dim_convert[], ticks, scale, tickformat, limits..., should_show) + graph, + [:tickvalues_unfiltered, limits], + :tick_indices_within_limits + ) do tickvalues_unfiltered, limits + return findall(tv -> is_within_limits(tv, limits), tickvalues_unfiltered) end - tickpositions = Observable(Point2f[]; ignore_equal_values = true) - tickstrings = Observable(Any[]; ignore_equal_values = false) + map!( + graph, + [:tickvalues_unfiltered, :tickstrings_unfiltered, :tick_indices_within_limits], + [:tickvalues, :tickstrings], + ) do tickvalues_unfiltered, tickstrings_unfiltered, indices + return tickvalues_unfiltered[indices], tickstrings_unfiltered[indices] + end - onany( - update_tickpos_string, parent, - Observable((tickstrings, tickpositions, tickvalues, pos_extents_horizontal, limits)), - tickvalues_labels_unfiltered, reversed, attrs.scale - ) + map!( + graph, + [:tickvalues, scale, :position, :extents, :horizontal, limits, reversed], + :tickpositions + ) do tickvalues, scale, position, extents_uncorrected, horizontal, limits, reversed - minortickvalues = Observable(Float64[]; ignore_equal_values = true) - minortickpositions = Observable(Point2f[]; ignore_equal_values = true) + # TODO: maybe move out? + extents = reversed ? reverse(extents_uncorrected) : extents_uncorrected + px_o = extents[1] + px_width = extents[2] - extents[1] + tickvalues_scaled = scale.(tickvalues) + tick_fractions = (tickvalues_scaled .- scale(limits[1])) ./ (scale(limits[2]) - scale(limits[1])) - onany(parent, tickvalues, minorticks, minorticksvisible, minorticksused) do tickvalues, minorticks, visible, used - if visible || used - minortickvalues[] = get_minor_tickvalues(minorticks, attrs.scale[], tickvalues, limits[]...) + tick_scenecoords = px_o .+ px_width .* tick_fractions + + if horizontal + return [Point2f(x, position) for x in tick_scenecoords] + else + return [Point2f(position, y) for y in tick_scenecoords] end - return end - onany(parent, minortickvalues, limits, pos_extents_horizontal) do mtv, limits, peh - update_minor_ticks(minortickpositions, limits, peh, mtv, attrs.scale[], reversed[]) + map!( + graph, + [:tickvalues, minorticks, minorticksvisible, minorticksused, scale, limits], + :minortickvalues, + init = Float64[] + ) do values, ticks, visible, used, scale, limits + if visible || used + return get_minor_tickvalues(ticks, scale, values, limits...) + else + return nothing + end end + ComputePipeline.mark_dirty!(graph.minortickvalues) - onany( - update_tick_obs, parent, - Observable(minorticksnode), Observable(horizontal), Observable(flipped), - minortickpositions, minortickalign, minorticksize, spinewidth + map!( + compute_minor_ticks, graph, + [limits, :position, :extents, :horizontal, :minortickvalues, scale, reversed], + :minortickpositions ) - onany( - update_ticklabel_node, parent, - # we don't want to update on these, so we wrap them in an observable: - Observable((horizontal, spinewidth, tickspace, ticklabelpad, tickvalues)), - Observable(ticklabel_annotation_obs), - labelgap, flipped, tickpositions, tickstrings + map!( + adjust_ticklabel_placement, graph, + [:tickpositions, :horizontal, flipped, spinewidth, :tickspace, ticklabelpad], + :ticklabel_position ) - onany( - update_tick_obs, parent, - Observable(ticksnode), Observable(horizontal), Observable(flipped), - tickpositions, tickalign, ticksize, spinewidth + map!( + calculated_aligned_ticks, graph, + [:horizontal, flipped, :tickpositions, tickalign, ticksize, spinewidth], + :ticksnode ) - linepoints = lift( - create_linepoints, parent, pos_extents_horizontal, flipped, spinewidth, trimspine, - tickpositions, tickwidth + map!( + calculated_aligned_ticks, graph, + [:horizontal, flipped, :minortickpositions, minortickalign, minorticksize, spinewidth], + :minorticksnode ) - decorations[:axisline] = linesegments!( - parent, linepoints, linewidth = spinewidth, visible = spinevisible, - color = spinecolor, inspectable = false, linestyle = nothing + ticklines = linesegments!( + parent, graph.ticksnode, linewidth = tickwidth, color = tickcolor, + linestyle = nothing, visible = ticksvisible, inspectable = false ) + decorations[:ticklines] = ticklines + translate!(ticklines, 0, 0, 10) - translate!(decorations[:axisline], 0, 0, 20) - - protrusion = Observable(0.0f0; ignore_equal_values = true) + minorticklines = linesegments!( + parent, graph.minorticksnode, linewidth = minortickwidth, color = minortickcolor, + linestyle = nothing, visible = minorticksvisible, inspectable = false + ) + decorations[:minorticklines] = minorticklines + translate!(minorticklines, 0, 0, 10) map!( - calculate_protrusion, parent, protrusion, - # we pass these as observables, to not trigger on them - Observable((horizontal, labeltext, ticklabel_annotation_obs)), - ticksvisible, label_with_suffix, labelvisible, labelpadding, tickspace, - ticklabelsvisible, actual_ticklabelspace, ticklabelpad, - # TODO: this can rely on a ...boundingbox_obs() function instead now - # we don't need these as arguments to calculate it, but we need to pass it because it - # indirectly influences the protrusion - labelfont, labelalign, labelrot, labelsize, ticklabelfont, tickalign + create_linepoints, graph, + [:position, :extents, :horizontal, flipped, spinewidth, trimspine, :tickpositions, tickwidth], + :linepoints ) + decorations[:axisline] = linesegments!( + parent, graph.linepoints, linewidth = spinewidth, visible = spinevisible, + color = spinecolor, inspectable = false, linestyle = nothing + ) + + translate!(decorations[:axisline], 0, 0, 20) + # trigger whole pipeline once to fill tickpositions and tickstrings # etc to avoid empty ticks bug #69 - notify(limits) + # notify(limits) # in order to dispatch to the correct text recipe later (normal text, latex, etc.) - # we need to have the ticklabel_annotation_obs populated once before adding the annotations - ticklabels_ref[] = text!( + # we need to have the tickstrings populated once before adding the annotations + ticklabels_plot = text!( parent, - ticklabel_annotation_obs, - align = realticklabelalign, + graph.ticklabel_position, + text = graph.tickstrings, + align = graph.realticklabelalign, rotation = ticklabelrotation, fontsize = ticklabelsize, font = ticklabelfont, @@ -580,19 +566,55 @@ function LineAxis(parent::Scene, attrs::Attributes) inspectable = false ) - decorations[:ticklabels] = ticklabels_ref[] + decorations[:ticklabels] = ticklabels_plot + + ticklabels_bbox = register_raw_string_boundingboxes!(ticklabels_plot) + on(ticklabels_bbox, update = true) do bbs + bb = reduce(update_boundingbox, bbs, init = Rect3f()) + update!(graph, :ticklabelbbox => bb) + return + end + + map!( + graph, + [labelvisible, :label_with_suffix, :ticklabelbbox, labelpadding], + :protrusion_labelspace + ) do visible, label, bbox, labelpadding + label_is_empty = iswhitespace(label) + if label_is_empty || !visible + return 0.0f0 + else + real_labelsize = widths(bbox)[ifelse(horizontal, 2, 1)] + return real_labelsize + labelpadding + end + end + + map!( + graph, + [ticklabelsvisible, :ticklabel_position, :tickspace], + :protrusion_tickspace + ) do visible, positions, tickspace + return (visible && !isempty(positions)) ? tickspace : 0.0f0 + end - # HACKY: the ticklabels in the string need to be updated - # before other stuff is triggered by them, which accesses the - # ticklabel boundingbox (which needs to be updated already) - # so we move the new listener from text! to the front + map!( + graph, + [ticklabelsvisible, :actual_ticklabelspace, ticklabelpad], + :protrusion_ticklabelgap + ) do visible, ticklabelspace, pad + needs_gap = (visible && ticklabelspace > 0) + return needs_gap ? ticklabelspace + pad : 0.0f0 + end - pushfirst!(ticklabel_annotation_obs.listeners, pop!(ticklabel_annotation_obs.listeners)) + map!(+, graph, [:protrusion_labelspace, :protrusion_tickspace, :protrusion_ticklabelgap], :protrusion) + protrusion = ComputePipeline.get_observable!(graph.protrusion) - # trigger calculation of ticklabel width once, now that it's not nothing anymore - # notify(ticklabelsvisible) + # TODO: + tickpositions = ComputePipeline.get_observable!(graph.tickpositions) + minortickpositions = ComputePipeline.get_observable!(graph.minortickpositions) - return LineAxis(parent, protrusion, attrs, decorations, tickpositions, tickvalues, tickstrings, minortickpositions, minortickvalues) + return LineAxis(parent, protrusion, attrs, decorations, tickpositions, minortickpositions) + # return LineAxis(parent, protrusion, attrs, decorations) end function tight_ticklabel_spacing!(la::LineAxis) diff --git a/Makie/src/makielayout/types.jl b/Makie/src/makielayout/types.jl index 3bda5f60bf6..95c956b4d2a 100644 --- a/Makie/src/makielayout/types.jl +++ b/Makie/src/makielayout/types.jl @@ -164,11 +164,11 @@ mutable struct LineAxis protrusion::Observable{Float32} attributes::Attributes elements::Dict{Symbol, Any} - tickpositions::Observable{Vector{Point2f}} - tickvalues::Observable{Vector{Float32}} - ticklabels::Observable{Vector{Any}} - minortickpositions::Observable{Vector{Point2f}} - minortickvalues::Observable{Vector{Float32}} + tickpositions::Observable{Vector{Point2f}} # <-- + # tickvalues::Observable{Vector{Float32}} + # ticklabels::Observable{Vector{Any}} + minortickpositions::Observable{Vector{Point2f}} # <-- + # minortickvalues::Observable{Vector{Float32}} end struct LimitReset end From 92b78242e9caab814a62d81ed0051d2c18393df6 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Tue, 3 Mar 2026 12:47:40 +0100 Subject: [PATCH 02/52] don't run initialize_block! twice in precompile --- Makie/src/precompiles.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/Makie/src/precompiles.jl b/Makie/src/precompiles.jl index a39b27bb125..ba44e6800e3 100644 --- a/Makie/src/precompiles.jl +++ b/Makie/src/precompiles.jl @@ -25,7 +25,6 @@ let logo() f = Figure() ax = Axis(f[1, 1]) - Makie.initialize_block!(ax) base_path = normpath(joinpath(dirname(pathof(Makie)), "..", "precompile")) shared_precompile = joinpath(base_path, "shared-precompile.jl") include(shared_precompile) From ec18f86b8cfa81edcbad6be343a1cc4b42db79bc Mon Sep 17 00:00:00 2001 From: ffreyer Date: Tue, 3 Mar 2026 13:00:15 +0100 Subject: [PATCH 03/52] deal with nothing --- Makie/src/makielayout/lineaxis.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 86c5006d054..f423eb4470c 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -295,7 +295,9 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) # TODO: Does this propagate enough on same value updates? # make sure we update tick calculation when needed obs = needs_tick_update_observable(dim_convert) - on(x -> ComputePipeline.mark_dirty!(dim_convert), obs) + if !isnothing(obs) + on(x -> ComputePipeline.mark_dirty!(dim_convert), obs) + end map!( calculate_real_ticklabel_align, graph, From 1bfebb515729929fc4b8952dfcc31c2886326fb9 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Tue, 3 Mar 2026 14:01:11 +0100 Subject: [PATCH 04/52] fix some stuff --- ComputePipeline/src/ComputePipeline.jl | 9 +++++++-- Makie/src/makielayout/lineaxis.jl | 7 ++++--- Makie/src/specapi.jl | 1 + 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index e0d6d6677af..211ba1a9707 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -491,6 +491,10 @@ function ComputeGraph() # update data for key in changeset + # task switches could cause the changeset to get mutated, so just + # intersect!() is not enough + haskey(graph.observables, key) || continue + val = graph.outputs[key][] obs = graph.observables[key] # Trust the graph to discard equal values. This doesn't work for @@ -498,7 +502,6 @@ function ComputeGraph() if !(key in graph.should_deepcopy) obs.val = val elseif val != obs[] # treat in-place updates - obs.val = deepcopy(val) else # same value (with deepcopy), skip update delete!(changeset, key) @@ -507,11 +510,13 @@ function ComputeGraph() # trigger observables for key in changeset + haskey(graph.observables, key) || continue notify(graph.observables[key]) end - # clear changeset after processing observables empty!(changeset) + + # clear changeset after processing observables return Consume(false) end diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index f423eb4470c..63446a92d01 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -402,8 +402,9 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) map!( build_label_with_unit_suffix, graph, [dim_convert, suffix_formatter, label, unit_in_label, use_short_unit], - :label_with_suffix + :label_with_suffix, init = Ref{Any}("") ) + ComputePipeline.mark_dirty!(graph.label_with_suffix) labeltext = text!( parent, graph.labelpos, text = graph.label_with_suffix, @@ -579,9 +580,9 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) map!( graph, - [labelvisible, :label_with_suffix, :ticklabelbbox, labelpadding], + [labelvisible, :label_with_suffix, :ticklabelbbox, labelpadding, :horizontal], :protrusion_labelspace - ) do visible, label, bbox, labelpadding + ) do visible, label, bbox, labelpadding, horizontal label_is_empty = iswhitespace(label) if label_is_empty || !visible return 0.0f0 diff --git a/Makie/src/specapi.jl b/Makie/src/specapi.jl index 742b7ff3464..c8c193cc585 100644 --- a/Makie/src/specapi.jl +++ b/Makie/src/specapi.jl @@ -1064,6 +1064,7 @@ function update_gridlayout!( if block isa Block disconnect!(block) elseif block isa GridLayout + isnothing(block.parent) && return i = findfirst(x -> x.content === block, block.parent.content) @assert !isnothing(i) "Could not find GridLayout() in its parent" GridLayoutBase.remove_from_gridlayout!(block.parent.content[i]) From 1c94ba5ccfc396d0714774c9a26b90ee4f0e3123 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Tue, 3 Mar 2026 14:51:12 +0100 Subject: [PATCH 05/52] run benchmark too --- .github/workflows/compilation-benchmark.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compilation-benchmark.yaml b/.github/workflows/compilation-benchmark.yaml index 0656b991b52..8f5e7e48801 100644 --- a/.github/workflows/compilation-benchmark.yaml +++ b/.github/workflows/compilation-benchmark.yaml @@ -6,7 +6,7 @@ on: - '*.md' branches: - master - - ff/render_pipeline_master + - ff/breaking-0.25 concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} From c0a35ef1f889c6d9cf8dfbf6e26f7152ea4e33d0 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Thu, 5 Mar 2026 01:08:44 +0100 Subject: [PATCH 06/52] fix recursive Observable updates discarding notifies --- ComputePipeline/src/ComputePipeline.jl | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index 211ba1a9707..f32d2e601cc 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -486,15 +486,15 @@ function ComputeGraph() Observables.ObserverFunction[], Observable[] ) - on(graph.onchange) do changeset - intersect!(changeset, keys(graph.observables)) + on(graph.onchange) do _changeset + # notifying observables may cause further updates to onchange which may + # corrupt state before we finish here. So copy changeset here and + # immediately prepare onchange for the next call + changeset = intersect(_changeset, keys(graph.observables)) + empty!(_changeset) # update data for key in changeset - # task switches could cause the changeset to get mutated, so just - # intersect!() is not enough - haskey(graph.observables, key) || continue - val = graph.outputs[key][] obs = graph.observables[key] # Trust the graph to discard equal values. This doesn't work for @@ -510,12 +510,9 @@ function ComputeGraph() # trigger observables for key in changeset - haskey(graph.observables, key) || continue notify(graph.observables[key]) end - empty!(changeset) - # clear changeset after processing observables return Consume(false) end From 4819fb75b70cd83f54fd709ee0393f645b7ea774 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Thu, 5 Mar 2026 01:09:17 +0100 Subject: [PATCH 07/52] more locks for safety --- ComputePipeline/src/ComputePipeline.jl | 27 +++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index f32d2e601cc..a34468a80e3 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -627,21 +627,26 @@ function Base.setindex!(computed::Computed, value) if computed.parent isa Input return setindex!(computed.parent, value) else - computed.value[] = value - mark_dirty!(computed) - update_observables!(computed) + @lock computed.parent.graph.lock begin + computed.value[] = value + mark_dirty!(computed) + update_observables!(computed) + end return value end end -function Base.setindex!(input::Input, value) - if is_same(input.value, value) +Base.setindex!(input::Input, value) = _setindex!(input, value, input.force_update) +function _setindex!(input::Input, value, force_update = false) + if !force_update && is_same(input.value, value) # Skip if the value is the same as before return value end - input.value = value - mark_dirty!(input) - update_observables!(input) + @lock input.graph.lock begin + input.value = value + mark_dirty!(input) + update_observables!(input) + end return value end @@ -657,11 +662,11 @@ function _setproperty!(attr::ComputeGraph, key::Symbol, value) end function Base.setproperty!(attr::ComputeGraph, key::Symbol, value) - return lock(attr.lock) do + @lock attr.lock begin _setproperty!(attr, key, value) - foreach(notify, attr.obs_to_update) - return value + update_observables!(attr) end + return value end """ From 342e24a8b04c438845ea092a57a983a806c59e15 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Thu, 5 Mar 2026 02:33:16 +0100 Subject: [PATCH 08/52] fix GLMakie not updating on just plot insertion --- GLMakie/src/plot-primitives.jl | 1 + 1 file changed, 1 insertion(+) diff --git a/GLMakie/src/plot-primitives.jl b/GLMakie/src/plot-primitives.jl index f7913b0639c..eea51c4d27c 100644 --- a/GLMakie/src/plot-primitives.jl +++ b/GLMakie/src/plot-primitives.jl @@ -27,6 +27,7 @@ function Base.insert!(screen::Screen, scene::Scene, @nospecialize(x::Plot)) insert!(screen, scene, x) end end + screen.requires_update = true return end From 92ebaacc9cd8a8bbda7255b742c3263cbed65a55 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Thu, 5 Mar 2026 21:54:09 +0100 Subject: [PATCH 09/52] Add set_type!() --- ComputePipeline/src/ComputePipeline.jl | 26 +++++++++++++++++++++----- Makie/src/makielayout/lineaxis.jl | 4 ++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index a34468a80e3..ba1c6ad7863 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -146,17 +146,24 @@ function TypedEdge(edge::ComputeEdge, f, inputs) outputs = ntuple(length(result)) do i v = result[i] isa RefValue ? result[i] : RefValue(result[i]) - edge.outputs[i].value = v # initialize to fully typed RefValue - return v + if isdefined(edge.outputs[i], :value) + edge.outputs[i][] = v[] # set value of existing node + else + edge.outputs[i].value = v # initialize to fully typed RefValue + end + return edge.outputs[i].value end foreach(node -> node.dirty = true, edge.outputs) elseif isnothing(result) outputs = ntuple(length(edge.outputs)) do i - v = RefValue(nothing) - edge.outputs[i].value = v # initialize to fully typed RefValue - return v + if isdefined(edge.outputs[i], :value) + edge.outputs[i][] = nothing + else + edge.outputs[i].value = RefValue(nothing) + end + return edge.outputs[i].value end foreach(node -> node.dirty = false, edge.outputs) @@ -2012,6 +2019,15 @@ function TypedEdge_no_call(edge::ComputeEdge) return TypedEdge(edge.callback, inputs, edge.inputs_dirty, outputs, edge.outputs) end +function set_type!(node::Computed, T::Type) + if isdefined(node, :value) + error("Node already initialized.") + else + node.value = Ref{T}() + end + return +end + include("io.jl") export Computed, ComputeEdge diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 63446a92d01..a744014c9ff 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -402,9 +402,9 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) map!( build_label_with_unit_suffix, graph, [dim_convert, suffix_formatter, label, unit_in_label, use_short_unit], - :label_with_suffix, init = Ref{Any}("") + :label_with_suffix ) - ComputePipeline.mark_dirty!(graph.label_with_suffix) + ComputePipeline.set_type!(graph.label_with_suffix, Any) labeltext = text!( parent, graph.labelpos, text = graph.label_with_suffix, From 6b82e4a96790e7110aa8ec16ddf72e208844a605 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:30:01 +0100 Subject: [PATCH 10/52] add flag to force updates to propagate in Inputs --- ComputePipeline/src/ComputePipeline.jl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index ba1c6ad7863..eb9982584c5 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -190,14 +190,15 @@ mutable struct Input{T} <: AbstractEdge output::Computed dirty::Bool dependents::Vector{ComputeEdge{T}} + force_update::Bool end Base.setproperty!(::Input, ::Symbol, ::Observable) = error("Setting the value of an ::Input to an Observable is not allowed") Base.setproperty!(::Input, ::Symbol, ::Computed) = error("Setting the value of an ::Input to a Computed is not allowed") -function Input(graph, name, value, f, output) +function Input(graph, name, value, f, output, force_update = false) validate_node_value(value) - return Input{ComputeGraph}(graph, name, value, f, output, true, ComputeEdge[]) + return Input{ComputeGraph}(graph, name, value, f, output, true, ComputeEdge[], force_update) end @@ -660,7 +661,9 @@ end function _setproperty!(attr::ComputeGraph, key::Symbol, value) input = attr.inputs[key] # Skip if the value is the same as before - is_same(input.value, value) && return value + if !input.force_update && is_same(input.value, value) + return value + end # can't notify observables immediately here, because update may call this # multiple times for a synchronized update (would cause desync) mark_dirty!(input) From 32f20fbd30c6e22fdec1c8f86bd30d744ce51f2c Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:31:26 +0100 Subject: [PATCH 11/52] add ExplicitUpdate wrapper to overwrite update propagation behavior --- ComputePipeline/src/ComputePipeline.jl | 16 ++++++------ ComputePipeline/src/utils.jl | 36 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 ComputePipeline/src/utils.jl diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index eb9982584c5..d44032877c5 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -476,8 +476,6 @@ function get_observable!(c::Computed; use_deepcopy = true) end end -include("observables_compat.jl") - # ComputeEdge(f) = ComputeEdge(f, Computed[]) function ComputeEdge(f, graph::ComputeGraph, inputs::Vector{Computed}) return ComputeEdge{ComputeGraph}( @@ -966,18 +964,18 @@ function set_result!(edge::TypedEdge, result) return set_result!(edge, rem, 1, next_val) end -is_same(@nospecialize(a), @nospecialize(b)) = false -is_same(a::Symbol, b::Symbol) = a == b -function is_same(a::T, b::T) where {T} +is_same(@nospecialize(old), @nospecialize(new)) = false +is_same(old::Symbol, new::Symbol) = old == new +function is_same(old::T, new::T) where {T} if isbitstype(T) # We can compare immutable isbits type per value with `===` - return a === b + return old === new else # For mutable types, we can only compare them if they're not pointing to the same object # If they are the same, we have to give up since we can't test if they got mutated in-between # Otherwise we can compare by equivalence - same_object = a === b - return same_object ? false : isequal(a, b) + same_object = old === new + return same_object ? false : isequal(old, new) end end @@ -2032,6 +2030,8 @@ function set_type!(node::Computed, T::Type) end include("io.jl") +include("observables_compat.jl") +include("utils.jl") export Computed, ComputeEdge export ComputeGraph diff --git a/ComputePipeline/src/utils.jl b/ComputePipeline/src/utils.jl new file mode 100644 index 00000000000..bbe83ec83ba --- /dev/null +++ b/ComputePipeline/src/utils.jl @@ -0,0 +1,36 @@ +struct ExplicitUpdate{T} + data::T + rule::Symbol + + function ExplicitUpdate{T}(data::T, rule::Symbol) where {T} + if !in(rule, (:force, :auto, :deny)) + error("Invalid value for should_update: :$should_update. Must be :force, :auto or :deny") + end + return new{T}(data, rule) + end +end + +""" + ExplicitUpdate(data, strategy) + +Wraps a value in ComputeGraph to mark its update strategy. Can be: +- `:force`: always propagate update +- `:deny`: never propagate update +- `:auto`: propagate update if `is_same(previous_data, new_data)` is false + +Unmarked data uses `:auto`. +""" +function ExplicitUpdate(data::T, rule::Symbol = :auto) where {T} + return ExplicitUpdate{T}(data, rule) +end + +is_same(old::ExplicitUpdate, new) = is_same(old.data, new) +is_same(old::ExplicitUpdate, new::ExplicitUpdate) = is_same(old.data, new) +function is_same(old, new::ExplicitUpdate) + if new.rule == :auto + return is_same(old, new.data) + else + # force should always fail the is_same discard, deny should always pass + return new.rule == :deny + end +end From 3966c658675137463904e8a33dd9ea4a7e5c57b4 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:31:54 +0100 Subject: [PATCH 12/52] improve some errors --- ComputePipeline/src/io.jl | 5 +++-- Makie/src/float32-scaling.jl | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ComputePipeline/src/io.jl b/ComputePipeline/src/io.jl index 26fb064f3e8..13c38e29d34 100644 --- a/ComputePipeline/src/io.jl +++ b/ComputePipeline/src/io.jl @@ -332,8 +332,9 @@ function trace_error(io::IO, edge::ComputeEdge, marked) if idx === nothing # All resolved print(io, " with edge inputs:") ioc = IOContext(io, :limit => true) - for input in edge.inputs - print(io, "\n ", input.name, " = ") + for (input, dirty) in zip(edge.inputs, edge.inputs_dirty) + c = ifelse(dirty, :normal, :light_black) + printstyled(io, "\n ", input.name, " = ", color = c) show(ioc, input.value[]) end println(io) diff --git a/Makie/src/float32-scaling.jl b/Makie/src/float32-scaling.jl index 1cb62e831e1..41c48b941f9 100644 --- a/Makie/src/float32-scaling.jl +++ b/Makie/src/float32-scaling.jl @@ -129,7 +129,7 @@ function update_limits!(c::Float32Convert, mini::VecTypes{3, Float64}, maxi::Vec low = linscale(mini) high = linscale(maxi) - @assert all(low .<= high) # TODO: Axis probably does that + @assert all(low .<= high) "$low .<= $high must be true" # TODO: Axis probably does that delta = high - low max_eps = Float64(eps(Float32)) * max.(abs.(low), abs.(high)) From 6d1f580e2843e74322938ca91d1f1e059fa42498 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:32:25 +0100 Subject: [PATCH 13/52] allow Block attribute init to be extended --- Makie/src/makielayout/blocks.jl | 35 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Makie/src/makielayout/blocks.jl b/Makie/src/makielayout/blocks.jl index 5909a2cbd9c..4737ff16731 100644 --- a/Makie/src/makielayout/blocks.jl +++ b/Makie/src/makielayout/blocks.jl @@ -596,6 +596,27 @@ end (::BlockAttributeConvert{<:RGBAf})(key, x) = to_color(x)::RGBAf (::BlockAttributeConvert{<:Makie.FreeTypeAbstraction.FTFont})(key, x) = to_font(x) +function add_attributes!(T::Type{<:Block}, graph, attributes) + return _add_attributes!(T, graph, attributes) +end + +function _add_attributes!(T::Type{<:Block}, graph::AbstractComputeGraph, attributes) + typedict = attribute_types(T) + for (key, attrib) in attributes + type = get(typedict, key, Any) + convert_attr = BlockAttributeConvert(type) + add_input!(convert_attr, graph, key, attrib) + converted = convert_attr(nothing, to_value(attrib)) + try + ComputePipeline.unsafe_init!(graph[key], Ref{type}(converted)) + catch e + @info "Failed to initialize Attribute $key with converted value $converted (input $attrib) to a type $type." + rethrow(e) + end + end + return +end + function _block(T::Type{<:Block}, fig_or_scene::Union{Figure, Scene}, args, kwdict::Dict, bbox; kwdict_complete = false) # first sort out all user kwargs that correspond to block attributes @@ -621,19 +642,7 @@ function _block(T::Type{<:Block}, fig_or_scene::Union{Figure, Scene}, args, kwdi end graph = ComputeGraph() - typedict = attribute_types(T) - for (key, attrib) in attributes - type = get(typedict, key, Any) - convert_attr = BlockAttributeConvert(type) - add_input!(convert_attr, graph, key, attrib) - converted = convert_attr(nothing, to_value(attrib)) - try - ComputePipeline.unsafe_init!(graph[key], Ref{type}(converted)) - catch e - @info "Failed to initialize Attribute $key with converted value $converted (input $attrib) to a type $type." - rethrow(e) - end - end + add_attributes!(T, graph, attributes) # create basic layout observables and connect attribute observables further down # after creating the block with its observable fields From 41a924b2ed0ab21643e649c019d0fe98b416a854 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:33:28 +0100 Subject: [PATCH 14/52] split up scenearea node so it's usable with computations --- Makie/src/makielayout/helpers.jl | 75 +++++++++++++++++--------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/Makie/src/makielayout/helpers.jl b/Makie/src/makielayout/helpers.jl index 337ad308bc7..4b848bed7f4 100644 --- a/Makie/src/makielayout/helpers.jl +++ b/Makie/src/makielayout/helpers.jl @@ -12,45 +12,52 @@ function round_to_IRect2D(r::Rect{2}) return Rect{2, Int}(newori, newwidth) end -function sceneareanode!(finalbbox, limits, aspect) - area_obs = Observable(Rect2i(); ignore_equal_values = true) - onany(finalbbox, limits, aspect; update = true) do bbox, limits, aspect - w = width(bbox) - h = height(bbox) - # as = mw / mh - as = w / h - mw, mh = w, h - - if aspect isa AxisAspect - aspect = aspect.aspect - elseif aspect isa DataAspect - aspect = limits.widths[1] / limits.widths[2] - end - - if !isnothing(aspect) - if as >= aspect - # too wide - mw *= aspect / as - else - # too high - mh *= as / aspect - end - end +function calculate_scenearea(finalbbox, limits, aspect) + w = width(finalbbox) + h = height(finalbbox) + + if w == 0 || h == 0 + x = left(finalbbox) + 0.5f0 * w + y = bottom(finalbbox) + 0.5f0 * h + return round_to_IRect2D(Rect2f(x, y, 0, 0)) + end - restw = w - mw - resth = h - mh + # as = mw / mh + viewport_aspect = w / h + mw, mh = w, h - # l = left(bbox) + alignment[1] * restw - # b = bottom(bbox) + alignment[2] * resth - l = left(bbox) + 0.5f0 * restw - b = bottom(bbox) + 0.5f0 * resth + if aspect isa AxisAspect + aspect = aspect.aspect + elseif aspect isa DataAspect + aspect = limits.widths[1] / limits.widths[2] + end - newbbox = BBox(l, l + mw, b, b + mh) - if all(isfinite, (newbbox.widths..., newbbox.origin...)) - area_obs[] = round_to_IRect2D(newbbox) + if !isnothing(aspect) + if viewport_aspect >= aspect + # too wide + mw *= aspect / viewport_aspect + else + # too high + mh *= viewport_aspect / aspect end - return end + + restw = w - mw + resth = h - mh + + # l = left(bbox) + alignment[1] * restw + # b = bottom(bbox) + alignment[2] * resth + l = left(finalbbox) + 0.5f0 * restw + b = bottom(finalbbox) + 0.5f0 * resth + + newbbox = BBox(l, l + mw, b, b + mh) + @assert all(isfinite, (newbbox.widths..., newbbox.origin...)) + return round_to_IRect2D(newbbox) +end + +function sceneareanode!(finalbbox, limits, aspect) + area_obs = Observable(Rect2i(); ignore_equal_values = true) + onany(calculate_scenearea, finalbbox, limits, aspect; update = true) return area_obs end From ec826123dfd6741ee85adb2232ed2a07e716d727 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:49:49 +0100 Subject: [PATCH 15/52] move limits to compute graph --- Makie/src/makielayout/blocks/axis.jl | 758 +++++++++++++++----------- Makie/src/makielayout/interactions.jl | 37 +- Makie/src/makielayout/types.jl | 6 +- 3 files changed, 453 insertions(+), 348 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 29b612accd9..5dedcab7756 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -59,13 +59,12 @@ function register_events!(ax, scene) return end -function update_axis_camera(scene::Scene, t, lims, xrev::Bool, yrev::Bool) +function calculate_axis_projection_matrix(scene::Scene, tf, lims, xrev::Bool, yrev::Bool) nearclip = -10_000f0 farclip = 10_000f0 # we are computing transformed camera position, so this isn't space dependent - tlims = Makie.apply_transform(t, lims) - camera = scene.camera + tlims = Makie.apply_transform(tf, lims) update_limits!(scene.float32convert, tlims) # update float32 scaling lims32 = f32_convert(scene.float32convert, tlims) # get scaled limits @@ -74,14 +73,11 @@ function update_axis_camera(scene::Scene, t, lims, xrev::Bool, yrev::Bool) leftright = xrev ? (right, left) : (left, right) bottomtop = yrev ? (top, bottom) : (bottom, top) - projection = Makie.orthographicprojection( + return Makie.orthographicprojection( Float32, leftright..., bottomtop..., nearclip, farclip ) - - Makie.set_proj_view!(camera, projection, Makie.Mat4f(Makie.I)) - return end function calculate_title_position(area, titlegap, align, xaxisposition, xaxisprotrusion, subtitle_height) @@ -124,26 +120,8 @@ function initialize_block!(ax::Axis; palette = nothing) elements = Dict{Symbol, Any}() ax.elements = elements - # initialize either with user limits, or pick defaults based on scales - # so that we don't immediately error - targetlimits = Observable{Rect2d}(defaultlimits(ax.limits[], ax.xscale[], ax.yscale[])) - finallimits = Observable{Rect2d}(targetlimits[]; ignore_equal_values = true) - setfield!(ax, :targetlimits, targetlimits) - setfield!(ax, :finallimits, finallimits) - - on(blockscene, targetlimits) do lims - # this should validate the targetlimits before anything else happens with them - # so there should be nothing before this lifting `targetlimits` - # we don't use finallimits because that's one step later and you - # already shouldn't set invalid targetlimits (even if they could - # theoretically be adjusted to fit somehow later?) - # and this way we can error pretty early - validate_limits_for_scales(lims, ax.xscale[], ax.yscale[]) - end - - scenearea = sceneareanode!(ax.layoutobservables.computedbbox, finallimits, ax.aspect) - - scene = Scene(blockscene, viewport = scenearea, visible = false) + scene = Scene(blockscene, viewport = Rect2i(0, 0, 0, 0), visible = false) + add_input!(ax.attributes, :viewport, scene.viewport) # Hide to block updates, will be unhidden! in constructor who calls this! @assert !scene.visible[] ax.scene = scene @@ -151,11 +129,14 @@ function initialize_block!(ax::Axis; palette = nothing) # or the other way around connect_conversions!(scene.conversions, ax) - # TODO: maybe use scenearea instead? - add_input!(ax.attributes, :viewport, scene.viewport) - setfield!(scene, :float32convert, Float32Convert()) + initialize_limit_computations!(ax) + + add_input!(ax.attributes, :computedbbox, ax.layoutobservables.computedbbox) + map!(calculate_scenearea, ax.attributes, [:computedbbox, :finallimits, :aspect], :scenearea) + connect!(scene.viewport, ax.scenearea) + if !isnothing(palette) # Backwards compatibility for when palette was part of axis! palette_attr = palette isa Attributes ? palette : Attributes(palette) @@ -165,39 +146,12 @@ function initialize_block!(ax::Axis; palette = nothing) # TODO: replace with mesh, however, CairoMakie needs a poly path for this signature # so it doesn't rasterize the scene background = poly!( - blockscene, scenearea; color = ax.backgroundcolor, inspectable = false, + blockscene, ax.viewport; color = ax.backgroundcolor, inspectable = false, shading = NoShading, strokecolor = :transparent ) translate!(background, 0, 0, -100) elements[:background] = background - block_limit_linking = Observable(false) - setfield!(ax, :block_limit_linking, block_limit_linking) - - ax.xaxislinks = Axis[] - ax.yaxislinks = Axis[] - - # When the transform function (xscale, yscale) of a plot changes we - # 1. communicate this change to plots (barplot needs this to make bars - # compatible with the new transform function/scale) - onany(blockscene, ax.xscale, ax.yscale, update = true) do xsc, ysc - scene.transformation.transform_func[] = (xsc, ysc) - return - end - - # 2. Update the limits of the plot - onany(blockscene, scene.transformation.transform_func, priority = -1, update = true) do _ - reset_limits!(ax) - end - - # 3. Update the view onto the plot (camera matrices) - onany( - blockscene, scene.transformation.transform_func, finallimits, - ax.xreversed, ax.yreversed; priority = -2 - ) do args... - update_axis_camera(scene, args...) - end - map!(ax.attributes, [:xaxisposition, :viewport], :xaxis_endpoints) do xaxisposition, area if xaxisposition === :bottom return bottomline(Rect2f(area)) @@ -246,12 +200,12 @@ function initialize_block!(ax::Axis; palette = nothing) [:yspinecolor, :yoppositespinecolor] ) - xlims = lift(xlimits, blockscene, finallimits; ignore_equal_values = true) - ylims = lift(ylimits, blockscene, finallimits; ignore_equal_values = true) + map!(xlimits, ax.attributes, :finallimits, :finalxlimits) + map!(ylimits, ax.attributes, :finallimits, :finalylimits) xaxis = LineAxis( blockscene, ComputePipeline.ComputeGraphView(ax.attributes, :xaxis), - endpoints = ax.xaxis_endpoints, limits = xlims, + endpoints = ax.xaxis_endpoints, limits = ax.finalxlimits, flipped = ax.xaxis_flipped, ticklabelrotation = ax.xticklabelrotation, ticklabelalign = ax.xticklabelalign, labelsize = ax.xlabelsize, labelpadding = ax.xlabelpadding, ticklabelpad = ax.xticklabelpad, labelvisible = ax.xlabelvisible, @@ -270,7 +224,7 @@ function initialize_block!(ax::Axis; palette = nothing) yaxis = LineAxis( blockscene, ComputePipeline.ComputeGraphView(ax.attributes, :yaxis), - endpoints = ax.yaxis_endpoints, limits = ylims, + endpoints = ax.yaxis_endpoints, limits = ax.finalylimits, flipped = ax.yaxis_flipped, ticklabelrotation = ax.yticklabelrotation, ticklabelalign = ax.yticklabelalign, labelsize = ax.ylabelsize, labelpadding = ax.ylabelpadding, ticklabelpad = ax.yticklabelpad, labelvisible = ax.ylabelvisible, @@ -547,37 +501,12 @@ function initialize_block!(ax::Axis; palette = nothing) register_events!(ax, scene) - # these are the user defined limits - on(blockscene, ax.limits) do _ - reset_limits!(ax) - end - - # these are the limits that we try to target, but they can be changed for correct aspects - on(blockscene, targetlimits) do tlims - update_linked_limits!(block_limit_linking, ax.xaxislinks, ax.yaxislinks, tlims) - end - - # compute limits that adhere to the limit aspect ratio whenever the targeted - # limits or the scene size change, because both influence the displayed ratio - onany(blockscene, scene.viewport, targetlimits) do pxa, lims - adjustlimits!(ax) - end - - # trigger limit pipeline once, with manual finallimits if they haven't changed from - # their initial value as they need to be triggered at least once to correctly set up - # projection matrices etc. - fl = finallimits[] - notify(ComputePipeline.get_observable!(ax.limits)) - if fl == finallimits[] - notify(finallimits) - end - # # Needed to fully initialize layouting for some reason... # notify(ComputePipeline.get_observable!(ax.xlabelpadding)) # notify(ComputePipeline.get_observable!(ax.ylabelpadding)) # Add them last, so we skip all the internal iterations from above! - add_input!(ax.scene.compute, :axis_limits, finallimits) + add_input!(ax.scene.compute, :axis_limits, ax.attributes.finallimits) map!(apply_transform, ax.scene.compute, [:transform_func, :axis_limits], :axis_limits_transformed) return ax @@ -593,6 +522,401 @@ function add_axis_limits!(plot) return end +################################################################################ +# Limits + +function add_attributes!(T::Type{<:Axis}, graph, attributes) + limits = pop!(attributes, :limits) + add_input!((k, v) -> Ref{Any}(convert_limit_attribute(v)), graph, :limits, limits) + # ComputePipeline.set_type!(graph.limits, Any) TODO: + _add_attributes!(T, graph, attributes) + return +end + +make_limit_update_explit(x::ComputePipeline.ExplicitUpdate) = x +make_limit_update_explit(x::Nothing) = ComputePipeline.ExplicitUpdate(x, :force) +make_limit_update_explit(x::Tuple{Nothing, <:Any}) = ComputePipeline.ExplicitUpdate(x, :force) +make_limit_update_explit(x::Tuple{<:Any, Nothing}) = ComputePipeline.ExplicitUpdate(x, :force) +make_limit_update_explit(x::Tuple{Nothing, Nothing}) = ComputePipeline.ExplicitUpdate(x, :force) +make_limit_update_explit(x::Tuple) = ComputePipeline.ExplicitUpdate(x, :auto) + +unwrap_explicit_update(x) = x +unwrap_explicit_update(x::ComputePipeline.ExplicitUpdate) = x.data + +function initialize_limit_computations!(ax) + attr = ax.attributes + + # Propagate same value updates, e.g. (nothing, nothing) -> (nothing, nothing) + attr.inputs[:limits].force_update = true + + # For plot boundingboxes in (x/y)limits -> local(x/y)limits we need the + # transform_func observable of plots to be up to date. To guarantee that we + # update it here, in the computation, so that the Observable will be update + # before any computation depending on transform_func runs + # (This is important for ticks which need finallimits to be up to date with + # the user set (x/y)scale. This requires the path from limits -> finallimits + # to be purely ComputeGraph computations.) + map!(attr, [:xscale, :yscale], :transform_func) do transform_func... + ax.scene.transformation.transform_func[] = transform_func + return transform_func + end + ComputePipeline.set_type!(attr.transform_func, Any) + + map!(attr, :transform_func, :inverse_transform_func) do tf + itf = inverse_transform(tf) + # nothing is uses to discard updates so we need something else here + return isnothing(itf) ? :nothing : itf + end + ComputePipeline.set_type!(attr.inverse_transform_func, Any) + + + # (x/y)lims!() need to be able to set limits across one dimension without + # affecting the limits of the other. To do that we need to mark one dimnesion + # as an update to discard and the other as a normal update with ExplicitUpdate. + # To avoid showing this to the user when fetching ax.limits[] we add another + # input here, where (x/y)lims!() can mark which dimension to deny + add_input!(attr, :_limit_update_rule, (:force, :force)) + + register_computation!( + attr, [:limits, :_limit_update_rule], [:xlimits, :ylimits], + ) do (limits, rule), changed, cached + if changed._limit_update_rule + # The update comes from (x/y)lims!() which explicitly set update rules + xlims = ComputePipeline.ExplicitUpdate(limits[1], rule[1]) + ylims = ComputePipeline.ExplicitUpdate(limits[2], rule[2]) + return xlims, ylims + else + # force propagation of nothing, compare for numbers + x = make_limit_update_explit.(limits) + return x + end + end + ComputePipeline.set_type!(attr.xlimits, Any) + ComputePipeline.set_type!(attr.ylimits, Any) + + + map!( + attr, + [:xlimits, :transform_func, :inverse_transform_func, :xautolimitmargin], + :localxlimits + ) do xlims, tf, itf, xautolimitmargin + return calculate_local_limits_from_plots( + ax, unwrap_explicit_update(xlims), 1, tf, itf, xautolimitmargin + ) + end + + map!( + attr, + [:ylimits, :transform_func, :inverse_transform_func, :yautolimitmargin], + :localylimits + ) do ylims, tf, itf, yautolimitmargin + return calculate_local_limits_from_plots( + ax, unwrap_explicit_update(ylims), 2, tf, itf, yautolimitmargin + ) + end + + setfield!(ax, :xaxislinks, Axis[]) + setfield!(ax, :yaxislinks, Axis[]) + + #= + Limit linking needs immediate updates. Consider linked ax1, ax2. If both + axes are updated before the backend pulls, the order in whcih the backend + pulls updates will determine whose limits are the "newest". + So whenever the user sets limits, or interacts with an axis we need that + change to be communicated to all linked axes asap. We make sure this happens + by adding an (unused) observable output to shared(x/y)limits here. This will + evaluate shared(x/y)limits whenever its input local(x/y)limits changes, + running the linked axis update in its callback. Note that the callback must + update shared(x/y)limits and not local(x/y)limits to not cause a feedback + loop. + Interactions must read from shared(x/y)limits of a dependent to get + up-to-date limits with linked axes, and update local(x/y)limits to trigger + the linked axis updates. + Note for refactoring - exiting and reentering the compute graph between + (x/y)scale and finallimits will cause ticks to update with the old + finallimits and the new (x/y)scale if (x/y)scale changes. + =# + + map!(attr, [:localxlimits, :xscale], :sharedxlimits) do lims, xscale + if !validate_limits_for_scale(lims, xscale) + error("Invalid x-limits $lims for scale $(xscale) which is defined on the interval $(defined_interval(xscale))") + end + + for link in ax.xaxislinks + link.sharedxlimits[] = lims + end + return lims + end + ComputePipeline.get_observable!(attr.sharedxlimits) + + map!(attr, [:localylimits, :yscale], :sharedylimits) do lims, yscale + if !validate_limits_for_scale(lims, yscale) + error("Invalid y-limits $lims for scale $(yscale) which is defined on the interval $(defined_interval(yscale))") + end + + for link in ax.yaxislinks + link.sharedylimits[] = lims + end + return lims + end + ComputePipeline.get_observable!(attr.sharedylimits) + + map!(attr, [:sharedxlimits, :sharedylimits], :targetlimits) do xlims, ylims + return BBox(xlims[1], xlims[2], ylims[1], ylims[2]) + end + + map!( + adjustlimits, attr, + [:targetlimits, :autolimitaspect, :viewport, :xautolimitmargin, :yautolimitmargin], + :finallimits + ) + + map!( + attr, + [:transform_func, :finallimits, :xreversed, :yreversed], + :projectionmatrix + ) do tf, lims, xrev, yrev + return calculate_axis_projection_matrix(ax.scene, tf, lims, xrev, yrev) + end + + # TODO: This could directly update scene.compute if we deprecate Camera + idm = Makie.Mat4f(Makie.I) + on(proj -> Makie.set_proj_view!(ax.scene.camera, proj, idm), attr.projectionmatrix, update = true) + + #= + limits normalize structure, entrypoint, xlims!(), ylims!(), + | | reset_limits!(), autolimits!(), LimitReset + ↓ ↓ + xlimits ylimits unwrap, force nothing propagation + ↓ ↓ + localxlimits localylimits mix in plot based limits, validation, target for interactions + ↓ ↓ + sharedxlimits sharedylimits updates linked axes sharedlimits as a side effect + ↓ ↓ |- either is a source for interactions + targetlimits to Rect2d + ↓ + viewport → finallimits → viewport aspect, margins + ↓ + projectionmatrix update f32convert, calculate camera matrix + ↓ + camera observables + =# + + return +end + +function getlimits(ax::Axis, dim, tf = ax.scene.transform_func, itf = inverse_transform(tf)) + # find all plots that don't have exclusion attributes set + # for this dimension + if !(dim in (1, 2)) + error("Dimension $dim not allowed. Only 1 or 2.") + end + + function exclude(plot) + # only use plots with autolimits = true + to_value(get(plot, dim == 1 ? :xautolimits : :yautolimits, true)) || return true + # only if they use data coordinates + is_data_space(plot) || return true + # only use visible plots for limits + return !to_value(get(plot, :visible, true)) + end + + # get all data limits, without the excluded plots + if (itf === nothing) || (itf === :nothing) + @warn "Axis transformation $tf does not define an `inverse_transform()`. This may result in a bad choice of limits due to model transformations being ignored." maxlog = 1 + bb = data_limits(ax.scene, exclude) + else + # get limits with transform_func and model applied + bb = boundingbox(ax.scene, exclude) + # then undo transform_func so that ticks can handle transform_func + # without ignoring translations, scaling or rotations from model + try + bb = apply_transform(itf, bb) + catch e + @warn "Failed to apply inverse transform $itf to bounding box $bb. Falling back on data_limits()." exception = e + bb = data_limits(ax.scene, exclude) + end + end + + # if there are no bboxes remaining, `nothing` signals that no limits could be determined + isfinite_rect(bb, dim) || return nothing + + # otherwise start with the first box + mini, maxi = minimum(bb), maximum(bb) + return (mini[dim], maxi[dim]) +end + +function autolimits( + ax::Axis, dim::Integer, + tf = ax.scene.transform_func, itf = inverse_transform(tf), + margin = ax.attributes[(:xautolimitmargin, :yautolimitmargin)[dim]][] + ) + # try getting x limits for the axis and then union them with linked axes + lims = getlimits(ax, dim, tf, itf) + + if isnothing(lims) + return defaultlimits(tf[dim]) + else + return expandlimits(lims, margin[1], margin[2], tf[dim]) + end +end + +# TODO: Is this supposed to be public api? +# autolimits is quite different now. may return nothing, no linking, no validate +# xautolimits(ax::Axis = current_axis()) = autolimits(ax, 1) +# yautolimits(ax::Axis = current_axis()) = autolimits(ax, 2) + +# Basically `reset_limits!()` without x/y/zauto = false +function calculate_local_limits_from_plots(ax, user_limits, idx, tf, itf, margin) + lims = if isnothing(user_limits) || user_limits[1] === nothing || user_limits[2] === nothing + l = autolimits(ax, idx, tf, itf, margin) + if user_limits === nothing + l + else + lo = user_limits[1] === nothing ? l[1] : user_limits[1] + hi = user_limits[2] === nothing ? l[2] : user_limits[2] + (lo, hi) + end + else + convert(Tuple{Float64, Float64}, tuple(user_limits...)) + end + + # Could not determine limits from plots, so discard the update by returning nothing + isnothing(lims) && return lims + + if !(lims[1] <= lims[2]) + dim = (:x, :y, :z)[idx] + error("Invalid $dim-limits as $(dim)lims[1] <= $(dim)lims[2] is not met for $lims.") + end + + return lims +end + +function calculate_local_limits(ax, user_limits, tf, itf) + lims = map(user_limits, eachindex(user_limits)) do lims, idx + calculate_local_limits(ax, lims, idx, tf, itf) + end + + bb = Rect(Vecf(first.(lims)), Vecf(last.(lims) .- first.(lims))) + return bb +end + +function adjustlimits(limits, autolimitaspect, viewport, xautolimitmargin, yautolimitmargin) + # in the simplest case, just update the final limits with the target limits + if isnothing(autolimitaspect) || width(viewport) == 0 || height(viewport) == 0 + return limits + end + + xlims = (left(limits), right(limits)) + ylims = (bottom(limits), top(limits)) + + viewport_aspect = width(viewport) / height(viewport) + data_aspect = (xlims[2] - xlims[1]) / (ylims[2] - ylims[1]) + aspect_ratio = data_aspect / viewport_aspect + + correction_factor = autolimitaspect / aspect_ratio + + if correction_factor > 1 + # need to go wider + marginsum = sum(xautolimitmargin) + ratios = (marginsum == 0) ? (0.5, 0.5) : (xautolimitmargin ./ marginsum) + xlims = expandlimits(xlims, ((correction_factor - 1) .* ratios)..., identity) # don't use scale here? + elseif correction_factor < 1 + # need to go taller + marginsum = sum(yautolimitmargin) + ratios = (marginsum == 0) ? (0.5, 0.5) : (yautolimitmargin ./ marginsum) + ylims = expandlimits(ylims, (((1 / correction_factor) - 1) .* ratios)..., identity) # don't use scale here? + end + + return BBox(xlims[1], xlims[2], ylims[1], ylims[2]) +end + +function xlims!(ax::Axis, xlims) + xlims = map(x -> convert_dim_value(ax, 1, x), xlims) + reversed = false + if length(xlims) != 2 + error("Invalid xlims length of $(length(xlims)), must be 2.") + elseif xlims[1] == xlims[2] && xlims[1] !== nothing + error("Can't set x limits to the same value $(xlims[1]).") + elseif all(x -> x isa Real, xlims) && xlims[1] > xlims[2] + xlims = reverse(xlims) + reversed = true + end + + # update xlims if they changed, keep ylims + update!( + ax.attributes, + limits = (xlims, ax.limits[][2]), + _limit_update_rule = (:auto, :deny), + xreversed = reversed + ) + + return nothing +end + +function Makie.ylims!(ax::Axis, ylims) + ylims = map(x -> convert_dim_value(ax, 2, x), ylims) + reversed = false + if length(ylims) != 2 + error("Invalid ylims length of $(length(ylims)), must be 2.") + elseif ylims[1] == ylims[2] && ylims[1] !== nothing + error("Can't set y limits to the same value $(ylims[1]).") + elseif all(x -> x isa Real, ylims) && ylims[1] > ylims[2] + ylims = reverse(ylims) + reversed = true + end + + # update ylims if they changed, keep xlims + update!( + ax.attributes, + limits = (ax.limits[][1], ylims), + _limit_update_rule = (:deny, :auto), + yreversed = reversed + ) + + return nothing +end + +function autolimits!(ax::Axis) + ax.limits = (nothing, nothing) + return +end + +function reset_limits!(ax::Axis; xauto = true, yauto = true) + # (x/y)auto = true means that we reset back to automatic limits for each + # dimension with `nothing`. I.e. we trigger a standard update of limits + # (x/y)auto = false means we keep whatever limits we currently have for + # every nothing in limits + + prev_sharedxlimits = ax.sharedxlimits[] + prev_sharedylimits = ax.sharedylimits[] + + ax.limits = ax.limits[] + + # recover previous limits for each *auto = false + # Writes to local limits to re-trigger axis linking + if !xauto + current_sharedxlimits = ax.sharedxlimits[] + ax.localxlimits[] = ifelse.( + isnothing.(unwrap_explicit_update(ax.xlimits[])), + prev_sharedxlimits, current_sharedxlimits + ) + end + + if !yauto + current_sharedylimits = ax.sharedylimits[] + ax.localylimits[] = ifelse.( + isnothing.(unwrap_explicit_update(ax.ylimits[])), + prev_sharedylimits, current_sharedylimits + ) + end + + return +end + + +################################################################################ + mirror_xticks(tp, ts, ta, vp, ap, sw) = mirror_ticks(tp, ts, ta, vp, :x, ap, sw) mirror_yticks(tp, ts, ta, vp, ap, sw) = mirror_ticks(tp, ts, ta, vp, :y, ap, sw) function mirror_ticks(tickpositions, ticksize, tickalign, viewport, side, axisposition, spinewidth) @@ -621,6 +945,7 @@ function mirror_ticks(tickpositions, ticksize, tickalign, viewport, side, axispo return points end +# TODO: This is not relevant to Axis anymore """ reset_limits!(ax; xauto = true, yauto = true) @@ -723,11 +1048,17 @@ function convert_limit_attribute(lims::Tuple{Any, Any, Any, Any}) end function convert_limit_attribute(lims::Tuple{Any, Any}) - _convert_single_limit(x) = x + _convert_single_limit(x::Nothing) = x _convert_single_limit(x::Interval) = endpoints(x) + _convert_single_limit(x::VecTypes{2}) = (x[1], x[2]) + function _convert_single_limit(x::AbstractArray) + length(x) == 2 || error("Each dimension of limits must have 2 values, the minimum and maximum.") + return (x[1], x[2]) + end return map(_convert_single_limit, lims) end +validate_limits_for_scales(lims::Rect, tf::Tuple) = validate_limits_for_scales(lims, tf...) function validate_limits_for_scales(lims::Rect, xsc, ysc) mi = minimum(lims) ma = maximum(lims) @@ -803,98 +1134,9 @@ function expandlimits(lims, margin_low, margin_high, scale) return lims end -function getlimits(la::Axis, dim) - # find all plots that don't have exclusion attributes set - # for this dimension - if !(dim in (1, 2)) - error("Dimension $dim not allowed. Only 1 or 2.") - end - - function exclude(plot) - # only use plots with autolimits = true - to_value(get(plot, dim == 1 ? :xautolimits : :yautolimits, true)) || return true - # only if they use data coordinates - is_data_space(plot) || return true - # only use visible plots for limits - return !to_value(get(plot, :visible, true)) - end - - # get all data limits, minus the excluded plots - tf = la.scene.transformation.transform_func[] - itf = inverse_transform(tf) - if itf === nothing - @warn "Axis transformation $tf does not define an `inverse_transform()`. This may result in a bad choice of limits due to model transformations being ignored." maxlog = 1 - bb = data_limits(la.scene, exclude) - else - # get limits with transform_func and model applied - bb = boundingbox(la.scene, exclude) - # then undo transform_func so that ticks can handle transform_func - # without ignoring translations, scaling or rotations from model - try - bb = apply_transform(itf, bb) - catch e - @warn "Failed to apply inverse transform $itf to bounding box $bb. Falling back on data_limits()." exception = e - bb = data_limits(la.scene, exclude) - end - end - - # if there are no bboxes remaining, `nothing` signals that no limits could be determined - isfinite_rect(bb, dim) || return nothing - - # otherwise start with the first box - mini, maxi = minimum(bb), maximum(bb) - return (mini[dim], maxi[dim]) -end - getxlimits(la::Axis) = getlimits(la, 1) getylimits(la::Axis) = getlimits(la, 2) -function update_linked_limits!(block_limit_linking, xaxislinks, yaxislinks, tlims) - - thisxlims = xlimits(tlims) - thisylims = ylimits(tlims) - - # only change linked axis if not prohibited from doing so because - # we're currently being updated by another axis' link - return if !block_limit_linking[] - - bothlinks = intersect(xaxislinks, yaxislinks) - xlinks = setdiff(xaxislinks, yaxislinks) - ylinks = setdiff(yaxislinks, xaxislinks) - - for link in bothlinks - otherlims = link.targetlimits[] - if tlims != otherlims - link.block_limit_linking[] = true - link.targetlimits[] = tlims - link.block_limit_linking[] = false - end - end - - for xlink in xlinks - otherlims = xlink.targetlimits[] - otherxlims = limits(otherlims, 1) - otherylims = limits(otherlims, 2) - if thisxlims != otherxlims - xlink.block_limit_linking[] = true - xlink.targetlimits[] = BBox(thisxlims[1], thisxlims[2], otherylims[1], otherylims[2]) - xlink.block_limit_linking[] = false - end - end - - for ylink in ylinks - otherlims = ylink.targetlimits[] - otherxlims = limits(otherlims, 1) - otherylims = limits(otherlims, 2) - if thisylims != otherylims - ylink.block_limit_linking[] = true - ylink.targetlimits[] = BBox(otherxlims[1], otherxlims[2], thisylims[1], thisylims[2]) - ylink.block_limit_linking[] = false - end - end - end -end - """ autolimits!() autolimits!(la::Axis) @@ -902,58 +1144,12 @@ end Reset manually specified limits of `la` to an automatically determined rectangle, that depends on the data limits of all plot objects in the axis, as well as the autolimit margins for x and y axis. The argument `la` defaults to `current_axis()`. """ -function autolimits!(ax::Axis) - # The compute graph will throw away same value updates, so we need to force - # the underlying observable to trigger with this: - if ax.limits[] == (nothing, nothing) - notify(ax.limits) - else - ax.limits = (nothing, nothing) - end - return -end function autolimits!() curr_ax = current_axis() isnothing(curr_ax) && throw(ArgumentError("Attempted to call `autolimits!` on `current_axis()`, but `current_axis()` returned nothing.")) return autolimits!(curr_ax) end -function autolimits(ax::Axis, dim::Integer) - # try getting x limits for the axis and then union them with linked axes - lims = getlimits(ax, dim) - - links = dim == 1 ? ax.xaxislinks : ax.yaxislinks - for link in links - if isnothing(lims) - lims = getlimits(link, dim) - else - newlims = getlimits(link, dim) - if !isnothing(newlims) - lims = limitunion(lims, newlims) - end - end - end - - dimsym = dim == 1 ? :x : :y - scale = getproperty(ax, Symbol(dimsym, :scale))[] - margin = getproperty(ax, Symbol(dimsym, :autolimitmargin))[] - if !isnothing(lims) - if !validate_limits_for_scale(lims, scale) - error("Found invalid $(dimsym)-limits $lims for scale $(scale) which is defined on the interval $(defined_interval(scale))") - end - lims = expandlimits(lims, margin[1], margin[2], scale) - end - - # if no limits have been found, use the targetlimits directly - if isnothing(lims) - lims = limits(ax.targetlimits[], dim) - end - return lims -end - -xautolimits(ax::Axis = current_axis()) = autolimits(ax, 1) -yautolimits(ax::Axis = current_axis()) = autolimits(ax, 2) - """ linkaxes!(a::Axis, others...) @@ -968,58 +1164,10 @@ function linkaxes!(a::Axis, others...) return linkaxes!([a, others...]) end -function adjustlimits!(la) - asp = la.autolimitaspect[] - target = la.targetlimits[] - area = la.scene.viewport[] - - # 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 - end - - 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 - - marginsum = sum(la.xautolimitmargin[]) - ratios = if marginsum == 0 - (0.5, 0.5) - else - (la.xautolimitmargin[] ./ marginsum) - end - - xlims = expandlimits(xlims, ((correction_factor - 1) .* ratios)..., identity) # don't use scale here? - elseif correction_factor < 1 - # need to go taller - - marginsum = sum(la.yautolimitmargin[]) - ratios = if marginsum == 0 - (0.5, 0.5) - else - (la.yautolimitmargin[] ./ marginsum) - end - ylims = expandlimits(ylims, (((1 / correction_factor) - 1) .* ratios)..., identity) # don't use scale here? - end - - bbox = BBox(xlims[1], xlims[2], ylims[1], ylims[2]) - la.finallimits[] = bbox - return -end - linkaxes!(dir::Symbol, a::Axis, others...) = linkaxes!(dir, [a, others...]) function linkaxes!(dir::Symbol, axes::Vector{Axis}) + (length(axes) < 2) && return all_links = Set{Axis}(axes) for ax in axes links = dir === :x ? ax.xaxislinks : ax.yaxislinks @@ -1037,7 +1185,14 @@ function linkaxes!(dir::Symbol, axes::Vector{Axis}) end end end - reset_limits!(first(axes)) + if links_changed + ax = first(axes) + if dir === :x + ax.localxlimits[] = ax.sharedxlimits[] + else + ax.localylimits[] = ax.sharedylimits[] + end + end return end @@ -1229,59 +1384,8 @@ function tight_ticklabel_spacing!(ax::Axis = current_axis()) return end -Makie.xlims!(ax::Axis, xlims::Interval) = Makie.xlims!(ax, endpoints(xlims)) -Makie.ylims!(ax::Axis, ylims::Interval) = Makie.ylims!(ax, endpoints(ylims)) - -function Makie.xlims!(ax::Axis, xlims) - xlims = map(x -> convert_dim_value(ax, 1, x), xlims) - if length(xlims) != 2 - error("Invalid xlims length of $(length(xlims)), must be 2.") - elseif xlims[1] == xlims[2] && xlims[1] !== nothing - error("Can't set x limits to the same value $(xlims[1]).") - elseif all(x -> x isa Real, xlims) && xlims[1] > xlims[2] - xlims = reverse(xlims) - ax.xreversed[] = true - else - ax.xreversed[] = false - end - - mlims = convert_limit_attribute(ax.limits[]) - ax.limits = (xlims, mlims[2]) - - # update xlims for linked axes - for xlink in ax.xaxislinks - xlink_mlims = convert_limit_attribute(xlink.limits[]) - xlink.limits = (xlims, xlink_mlims[2]) - end - - reset_limits!(ax, yauto = false) - return nothing -end - -function Makie.ylims!(ax::Axis, ylims) - ylims = map(x -> convert_dim_value(ax, 2, x), ylims) - if length(ylims) != 2 - error("Invalid ylims length of $(length(ylims)), must be 2.") - elseif ylims[1] == ylims[2] && ylims[1] !== nothing - error("Can't set y limits to the same value $(ylims[1]).") - elseif all(x -> x isa Real, ylims) && ylims[1] > ylims[2] - ylims = reverse(ylims) - ax.yreversed[] = true - else - ax.yreversed[] = false - end - mlims = convert_limit_attribute(ax.limits[]) - ax.limits = (mlims[1], ylims) - - # update ylims for linked axes - for ylink in ax.yaxislinks - ylink_mlims = convert_limit_attribute(ylink.limits[]) - ylink.limits = (ylink_mlims[1], ylims) - end - - reset_limits!(ax, xauto = false) - return nothing -end +xlims!(ax::Axis, xlims::Interval) = xlims!(ax, endpoints(xlims)) +ylims!(ax::Axis, ylims::Interval) = ylims!(ax, endpoints(ylims)) """ xlims!(ax, low, high) diff --git a/Makie/src/makielayout/interactions.jl b/Makie/src/makielayout/interactions.jl index d9d0846eef1..143fa0cc83b 100644 --- a/Makie/src/makielayout/interactions.jl +++ b/Makie/src/makielayout/interactions.jl @@ -231,6 +231,8 @@ function process_interaction(s::ScrollZoom, event::ScrollEvent, ax::Axis) # use vertical zoom zoom = event.y + # Note: This must read from shared(x/y)limits or targetlimits to pull in the + # correct limits when axes are linked tlimits = ax.targetlimits xzoomlock = ax.xzoomlock yzoomlock = ax.yzoomlock @@ -286,20 +288,23 @@ function process_interaction(s::ScrollZoom, event::ScrollEvent, ax::Axis) Rectd(newxorigin, newyorigin, newxwidth, newywidth) end inv_transf = Makie.inverse_transform(transf) - tlimits[] = Makie.apply_transform(inv_transf, newrect_trans) + new_bb = Makie.apply_transform(inv_transf, newrect_trans) + # Note: And it must write to an input of shared(x/y) limits to correctly + # update other linked axes + ax.localxlimits[] = (left(new_bb), right(new_bb)) + ax.localylimits[] = (bottom(new_bb), top(new_bb)) end # NOTE this might be problematic if if we add scrolling to something like Menu return Consume(true) end -function process_interaction(dp::DragPan, event::MouseEvent, ax) +function process_interaction(dp::DragPan, event::MouseEvent, ax::Axis) if event.type !== to_drag_event(ax.panbutton[]) return Consume(false) end - tlimits = ax.targetlimits xpanlock = ax.xpanlock ypanlock = ax.ypanlock xpankey = ax.xpankey @@ -321,20 +326,13 @@ function process_interaction(dp::DragPan, event::MouseEvent, ax) 0.5 .+ 0.5 end - xscale = ax.xscale[] - yscale = ax.yscale[] - - transf = (xscale, yscale) - tlimits_trans = Makie.apply_transform(transf, tlimits[]) + # Note: This must read from shared(x/y)limits or targetlimits to pull in the + # correct limits when axes are linked + bb = ax.targetlimits[] + transf = ax.transform_func[] + tlimits_trans = Makie.apply_transform(transf, bb) movement_frac = mp_axfraction .- mp_axfraction_prev - - xscale = ax.xscale[] - yscale = ax.yscale[] - - transf = (xscale, yscale) - tlimits_trans = Makie.apply_transform(transf, tlimits[]) - xori, yori = tlimits_trans.origin .- movement_frac .* widths(tlimits_trans) if xpanlock[] || ispressed(scene, ypankey[]) @@ -345,11 +343,16 @@ function process_interaction(dp::DragPan, event::MouseEvent, ax) yori = tlimits_trans.origin[2] end + # TODO: unnecessary now? timed_ticklabelspace_reset(ax, dp.reset_timer, dp.prev_xticklabelspace, dp.prev_yticklabelspace, dp.reset_delay) - inv_transf = Makie.inverse_transform(transf) + inv_transf = ax.inverse_transform_func[] newrect_trans = Rectd(Vec2(xori, yori), widths(tlimits_trans)) - tlimits[] = Makie.apply_transform(inv_transf, newrect_trans) + new_bb = Makie.apply_transform(inv_transf, newrect_trans) + # Note: And it must write to an input of shared(x/y) limits to correctly + # update other linked axes + ax.localxlimits[] = (left(new_bb), right(new_bb)) + ax.localylimits[] = (bottom(new_bb), top(new_bb)) return Consume(true) end diff --git a/Makie/src/makielayout/types.jl b/Makie/src/makielayout/types.jl index 95c956b4d2a..c32025dd2d2 100644 --- a/Makie/src/makielayout/types.jl +++ b/Makie/src/makielayout/types.jl @@ -281,9 +281,6 @@ Axis(fig_or_scene; palette = nothing, kwargs...) scene::Scene xaxislinks::Vector{Axis} yaxislinks::Vector{Axis} - targetlimits::Observable{Rect2d} - finallimits::Observable{Rect2d} - block_limit_linking::Observable{Bool} mouseeventhandle::MouseEventHandle scrollevents::Observable{ScrollEvent} keysevents::Observable{KeysEvent} @@ -786,7 +783,8 @@ end function RectangleZoom(ax::Axis; kw...) return RectangleZoom(ax; kw...) do newlims if !(0 in widths(newlims)) - ax.targetlimits[] = newlims + ax.localxlimits[] = (left(newlims), right(newlims)) + ax.localylimits[] = (bottom(newlims), top(newlims)) end return end From 9126fc75254322ed1491f96711da64efb85dd63b Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 00:50:05 +0100 Subject: [PATCH 16/52] minor test fixes --- Makie/test/SceneLike/makielayout.jl | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Makie/test/SceneLike/makielayout.jl b/Makie/test/SceneLike/makielayout.jl index 649209d0e1f..e28f342d11e 100644 --- a/Makie/test/SceneLike/makielayout.jl +++ b/Makie/test/SceneLike/makielayout.jl @@ -65,7 +65,9 @@ end @testset "Axis limits basics" begin f = Figure() ax = Axis(f[1, 1], limits = (nothing, nothing)) - ax.targetlimits[] = BBox(0, 10, 0, 20) + # ax.targetlimits[] = BBox(0, 10, 0, 20) + ax.localxlimits[] = (0, 10) + ax.localylimits[] = (0, 20) @test ax.finallimits[] == BBox(0, 10, 0, 20) @test ax.limits[] == (nothing, nothing) xlims!(ax, -10, 10) @@ -104,22 +106,22 @@ end @test ax.targetlimits[] == BBox(0, 5, 0, 6) @test ax.finallimits[] == BBox(0, 5, 0, 6) xlims!(ax, [-10, 10]) - @test ax.limits[] == ([-10, 10], nothing) + @test ax.limits[] == ((-10, 10), nothing) @test ax.targetlimits[] == BBox(-10, 10, 0, 6) @test ax.finallimits[] == BBox(-10, 10, 0, 6) scatter!(Point2f(11, 12)) reset_limits!(ax) - @test ax.limits[] == ([-10, 10], nothing) + @test ax.limits[] == ((-10, 10), nothing) @test ax.targetlimits[] == BBox(-10, 10, 0, 12) @test ax.finallimits[] == BBox(-10, 10, 0, 12) autolimits!(ax) ylims!(ax, [5, 7]) - @test ax.limits[] == (nothing, [5, 7]) + @test ax.limits[] == (nothing, (5, 7)) @test ax.targetlimits[] == BBox(0, 11, 5, 7) @test ax.finallimits[] == BBox(0, 11, 5, 7) scatter!(Point2f(-5, -7)) reset_limits!(ax) - @test ax.limits[] == (nothing, [5, 7]) + @test ax.limits[] == (nothing, (5, 7)) @test ax.targetlimits[] == BBox(-5, 11, 5, 7) @test ax.finallimits[] == BBox(-5, 11, 5, 7) @test_throws MethodError limits!(f[1, 1], -1, 1, -1, 1) @@ -259,7 +261,7 @@ end # https://github.com/MakieOrg/Makie.jl/issues/2278 fig = Figure() cbar = Colorbar(fig[1, 1], colormap = :viridis, colorrange = Vec2f(0, 1)) - ticklabel_strings = first.(cbar.axis.elements[:ticklabels].arg1[]) + ticklabel_strings = cbar.axis.elements[:ticklabels].text[] @test ticklabel_strings[1] == "0.0" @test ticklabel_strings[end] == "1.0" end From 8f568e2add2580d2743c94cea5aee513f4bc7979 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 02:06:42 +0100 Subject: [PATCH 17/52] guard against infinite recursion in axislinks --- Makie/src/makielayout/blocks/axis.jl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 5dedcab7756..2fefeaa97bf 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -643,6 +643,8 @@ function initialize_limit_computations!(ax) end for link in ax.xaxislinks + link === ax && continue + # The world ends if this runs with link being this Axis link.sharedxlimits[] = lims end return lims @@ -655,6 +657,7 @@ function initialize_limit_computations!(ax) end for link in ax.yaxislinks + link === ax && continue link.sharedylimits[] = lims end return lims From 12af95bf99dbad3ddfe5c6bb4aa3f1845de559cf Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 03:53:09 +0100 Subject: [PATCH 18/52] fix incorrect bbox in protrusions --- ComputePipeline/src/ComputePipeline.jl | 3 +++ Makie/src/makielayout/lineaxis.jl | 14 +++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index d44032877c5..b2bb343c9ca 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -864,6 +864,9 @@ function Base.getindex(attr::ComputeGraph, key::Symbol) end end +function Base.propertynames(attr::ComputeGraphView) + return collect(keys(attr.parent.nesting.keytables[attr.nested_trace.next_index])) +end function Base.getproperty(attr::ComputeGraphView, key::Symbol) hasfield(ComputeGraphView, key) && return getfield(attr, key) return getindex(attr, key) diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index a744014c9ff..7fce5fae744 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -414,15 +414,23 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) markerspace = :data, inspectable = false ) + _labelbbox = register_raw_string_boundingboxes!(labeltext) + add_input!(graph, :labelbbox, Rect2d()) + labelbbox = map(_labelbbox) do bbs + bb = Rect2d(bbs[1]) + update!(graph, :labelbbox => bb) + return bb + end + # translate axis labels on explicit rotations # in order to prevent plot and axis overlap - onany(parent, labelrotation, flipped, graph.horizontal) do labelrotation, flipped, horizontal + onany(parent, labelrotation, flipped, graph.horizontal, labelbbox) do labelrotation, flipped, horizontal, bb xs::Float32, ys::Float32 = if labelrotation isa Automatic 0.0f0, 0.0f0 else # There is only one string here and if we only case about widths # we don't need to include positions through a higher level bbox function - wx, wy = widths(string_boundingboxes(labeltext)[1]) + wx, wy = widths(bb) sign::Int = flipped ? 1 : -1 if horizontal 0.0f0, Float32(sign * 0.5f0 * wy) @@ -580,7 +588,7 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) map!( graph, - [labelvisible, :label_with_suffix, :ticklabelbbox, labelpadding, :horizontal], + [labelvisible, :label_with_suffix, :labelbbox, labelpadding, :horizontal], :protrusion_labelspace ) do visible, label, bbox, labelpadding, horizontal label_is_empty = iswhitespace(label) From 740627323ee60b8d8fba403485321b3a0a50de33 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 13:58:14 +0100 Subject: [PATCH 19/52] fix dim converts --- Makie/ext/MakieDynamicQuantitiesExt.jl | 4 +++- Makie/src/makielayout/blocks/axis.jl | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Makie/ext/MakieDynamicQuantitiesExt.jl b/Makie/ext/MakieDynamicQuantitiesExt.jl index b6dbb268b12..c3e6547e436 100644 --- a/Makie/ext/MakieDynamicQuantitiesExt.jl +++ b/Makie/ext/MakieDynamicQuantitiesExt.jl @@ -37,7 +37,9 @@ function M.get_ticks(conversion::M.DQConversion, ticks, scale, formatter, vmin, return tick_vals, labels end -M.get_label_suffix(conversion::M.DQConversion) = unit_string(conversion.quantity[]) +function M.get_label_suffix(conversion::M.DQConversion) + return conversion.quantity[] isa M.Automatic ? "" : unit_string(conversion.quantity[]) +end function M.convert_dim_value(conversion::M.DQConversion, attr, values, last_values) if conversion.quantity[] isa M.Automatic diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 2fefeaa97bf..a5bf1179f48 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -217,7 +217,7 @@ function initialize_block!(ax::Axis; palette = nothing) minorticksvisible = ax.xminorticksvisible, minortickalign = ax.xminortickalign, minorticksize = ax.xminorticksize, minortickwidth = ax.xminortickwidth, minortickcolor = ax.xminortickcolor, minorticks = ax.xminorticks, scale = ax.xscale, minorticksused = ax.xminorgridvisible, unit_in_ticklabel = ax.x_unit_in_ticklabel, unit_in_label = ax.x_unit_in_label, - label_suffix = ax.xlabel_suffix, use_short_unit = ax.use_short_x_units + suffix_formatter = ax.xlabel_suffix, use_short_unit = ax.use_short_x_units ) ax.xaxis = xaxis @@ -236,7 +236,7 @@ function initialize_block!(ax::Axis; palette = nothing) minorticksvisible = ax.yminorticksvisible, minortickalign = ax.yminortickalign, minorticksize = ax.yminorticksize, minortickwidth = ax.yminortickwidth, minortickcolor = ax.yminortickcolor, minorticks = ax.yminorticks, scale = ax.yscale, minorticksused = ax.yminorgridvisible, unit_in_ticklabel = ax.y_unit_in_ticklabel, unit_in_label = ax.y_unit_in_label, - label_suffix = ax.ylabel_suffix, use_short_unit = ax.use_short_y_units + suffix_formatter = ax.ylabel_suffix, use_short_unit = ax.use_short_y_units ) ax.yaxis = yaxis From d677ee2b1f2bdf65337daebdf64467a2ac5821bd Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 14:40:46 +0100 Subject: [PATCH 20/52] fix linked layout spec not initializing links --- Makie/src/specapi.jl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makie/src/specapi.jl b/Makie/src/specapi.jl index c8c193cc585..91ff852e158 100644 --- a/Makie/src/specapi.jl +++ b/Makie/src/specapi.jl @@ -971,6 +971,12 @@ function update_axis_links!(gridspec, all_layoutables) unique!(ax.yaxislinks) end + # trigger linking of axes + for (spec, ax) in axes + ax.localxlimits[] = ax.sharedxlimits[] + ax.localylimits[] = ax.sharedylimits[] + end + return end From 92b8c074c8d926d2b1d9197faf5e9cb7da05be5f Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 14:45:25 +0100 Subject: [PATCH 21/52] fix axis label translation --- Makie/src/makielayout/lineaxis.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 7fce5fae744..26832526439 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -424,12 +424,12 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) # translate axis labels on explicit rotations # in order to prevent plot and axis overlap - onany(parent, labelrotation, flipped, graph.horizontal, labelbbox) do labelrotation, flipped, horizontal, bb + onany( + parent, labelrotation, flipped, graph.horizontal, labelbbox, update = true + ) do labelrotation, flipped, horizontal, bb xs::Float32, ys::Float32 = if labelrotation isa Automatic 0.0f0, 0.0f0 else - # There is only one string here and if we only case about widths - # we don't need to include positions through a higher level bbox function wx, wy = widths(bb) sign::Int = flipped ? 1 : -1 if horizontal From b0a9808de34d92a8a79672dfb296f6c98f240f2d Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 17:21:30 +0100 Subject: [PATCH 22/52] fix recursion loop in Colorbar protrusion --- Makie/src/basic_recipes/datashader.jl | 4 +++- Makie/src/makielayout/blocks/colorbar.jl | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Makie/src/basic_recipes/datashader.jl b/Makie/src/basic_recipes/datashader.jl index b10f8204951..df4bf6cbeac 100644 --- a/Makie/src/basic_recipes/datashader.jl +++ b/Makie/src/basic_recipes/datashader.jl @@ -476,7 +476,9 @@ function Makie.plot!(p::DataShader{<:Tuple{Dict{String, Vector{Point{2, Float32} end data_limits(p::DataShader)::Rect3d = p.data_limits[] -boundingbox(p::DataShader, space::Symbol = :data)::Rect3d = apply_transform_and_model(p, p.data_limits[]) +function boundingbox(p::DataShader, space::Symbol = :data)::Rect3d + return apply_transform_and_model(p, p.data_limits[]) +end function convert_arguments(P::Type{<:Union{MeshScatter, Image, Surface, Contour, Contour3d}}, canvas::Canvas, operation = automatic, local_operation = identity) pixel = Aggregation.get_aggregation(canvas; operation = operation, local_operation = local_operation) diff --git a/Makie/src/makielayout/blocks/colorbar.jl b/Makie/src/makielayout/blocks/colorbar.jl index 8cc2fd33c4f..2b96e8cde75 100644 --- a/Makie/src/makielayout/blocks/colorbar.jl +++ b/Makie/src/makielayout/blocks/colorbar.jl @@ -451,7 +451,10 @@ function initialize_block!(cb::Colorbar) end end - cb.layoutobservables.protrusions[] = GridLayoutBase.RectSides{Float32}(left, right, bottom, top) + rs = GridLayoutBase.RectSides{Float32}(left, right, bottom, top) + if rs != cb.layoutobservables.protrusions[] + cb.layoutobservables.protrusions[] = rs + end end # trigger protrusions with one of the attributes From 3102d9886beaa5c0806bc7f7dbf54018e89aa257 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 17:32:04 +0100 Subject: [PATCH 23/52] minor test cleanup --- ReferenceTests/src/tests/updating.jl | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ReferenceTests/src/tests/updating.jl b/ReferenceTests/src/tests/updating.jl index 2a69a98d218..59e3f8b557e 100644 --- a/ReferenceTests/src/tests/updating.jl +++ b/ReferenceTests/src/tests/updating.jl @@ -122,10 +122,8 @@ end @reference_test "deletion and observable args" begin obs = Observable(1:5) f, ax, pl = scatter(obs; markersize = 150) - s = display(f) - # So, for GLMakie it will be 2, since we register an additional listener for - # State changes for the on demand renderloop - @test length(obs.listeners) in (1, 2) + display(f) + @test length(obs.listeners) in 1 delete!(ax, pl) @test length(obs.listeners) == 0 # ugh, hard to synchronize this with WGLMakie, so, we need to sleep for now to make sure the change makes it to the browser From b278fab0dab8653b78ddf705db85d6870ae61669 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 19:20:31 +0100 Subject: [PATCH 24/52] default linked axes in SpecApi to limit union --- Makie/src/specapi.jl | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Makie/src/specapi.jl b/Makie/src/specapi.jl index 91ff852e158..2b9cb8ae5fa 100644 --- a/Makie/src/specapi.jl +++ b/Makie/src/specapi.jl @@ -941,6 +941,22 @@ function replace_links!(axis_links::Vector, new_links::Set) return true end +function linked_limit_union(ax::Axis) + x0, x1 = ax.sharedxlimits[] + y0, y1 = ax.sharedylimits[] + for other in ax.xaxislinks + a, b = other.sharedxlimits[] + x0 = min(x0, a) + x1 = max(x1, b) + end + for other in ax.yaxislinks + a, b = other.sharedylimits[] + y0 = min(y0, a) + y1 = max(y1, b) + end + return (x0, x1), (y0, y1) +end + function update_axis_links!(gridspec, all_layoutables) # axes that should be linked axes = Dict{BlockSpec, Axis}() @@ -973,8 +989,9 @@ function update_axis_links!(gridspec, all_layoutables) # trigger linking of axes for (spec, ax) in axes - ax.localxlimits[] = ax.sharedxlimits[] - ax.localylimits[] = ax.sharedylimits[] + xlims, ylims = linked_limit_union(ax) + ax.localxlimits[] = xlims + ax.localylimits[] = ylims end return From d96ac7b94fb730c024457ffbb2d19981d93b006d Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 19:21:59 +0100 Subject: [PATCH 25/52] fix autolimits with linked axes (same value reset) --- Makie/src/makielayout/blocks/axis.jl | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index a5bf1179f48..0866fccb5ba 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -600,20 +600,26 @@ function initialize_limit_computations!(ax) [:xlimits, :transform_func, :inverse_transform_func, :xautolimitmargin], :localxlimits ) do xlims, tf, itf, xautolimitmargin - return calculate_local_limits_from_plots( + lims = calculate_local_limits_from_plots( ax, unwrap_explicit_update(xlims), 1, tf, itf, xautolimitmargin ) + # make sure this can reset sharedlimits even if this is reset to the same + # value (The update rules have already been applied in the previous step) + return ComputePipeline.ExplicitUpdate(lims, :force) end + ComputePipeline.set_type!(attr.localxlimits, Union{Tuple{Float64, Float64}, ComputePipeline.ExplicitUpdate{Tuple{Float64, Float64}}}) map!( attr, [:ylimits, :transform_func, :inverse_transform_func, :yautolimitmargin], :localylimits ) do ylims, tf, itf, yautolimitmargin - return calculate_local_limits_from_plots( + lims = calculate_local_limits_from_plots( ax, unwrap_explicit_update(ylims), 2, tf, itf, yautolimitmargin ) + return ComputePipeline.ExplicitUpdate(lims, :force) end + ComputePipeline.set_type!(attr.localylimits, Union{Tuple{Float64, Float64}, ComputePipeline.ExplicitUpdate{Tuple{Float64, Float64}}}) setfield!(ax, :xaxislinks, Axis[]) setfield!(ax, :yaxislinks, Axis[]) @@ -637,7 +643,8 @@ function initialize_limit_computations!(ax) finallimits and the new (x/y)scale if (x/y)scale changes. =# - map!(attr, [:localxlimits, :xscale], :sharedxlimits) do lims, xscale + map!(attr, [:localxlimits, :xscale], :sharedxlimits) do _lims, xscale + lims = unwrap_explicit_update(_lims) if !validate_limits_for_scale(lims, xscale) error("Invalid x-limits $lims for scale $(xscale) which is defined on the interval $(defined_interval(xscale))") end @@ -651,7 +658,8 @@ function initialize_limit_computations!(ax) end ComputePipeline.get_observable!(attr.sharedxlimits) - map!(attr, [:localylimits, :yscale], :sharedylimits) do lims, yscale + map!(attr, [:localylimits, :yscale], :sharedylimits) do _lims, yscale + lims = unwrap_explicit_update(_lims) if !validate_limits_for_scale(lims, yscale) error("Invalid y-limits $lims for scale $(yscale) which is defined on the interval $(defined_interval(yscale))") end From 2cefe4da920f2f384ed9034f980552b9ddff941e Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 19:23:18 +0100 Subject: [PATCH 26/52] remove not yet defined type --- Makie/src/specapi.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makie/src/specapi.jl b/Makie/src/specapi.jl index 2b9cb8ae5fa..be36e9991d0 100644 --- a/Makie/src/specapi.jl +++ b/Makie/src/specapi.jl @@ -941,7 +941,7 @@ function replace_links!(axis_links::Vector, new_links::Set) return true end -function linked_limit_union(ax::Axis) +function linked_limit_union(ax) x0, x1 = ax.sharedxlimits[] y0, y1 = ax.sharedylimits[] for other in ax.xaxislinks From 4c92a2772506ce08361c775bb9111ab60b817ad0 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 19:41:58 +0100 Subject: [PATCH 27/52] fix type to Float64 --- Makie/src/makielayout/blocks/axis.jl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 0866fccb5ba..129ffafad1e 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -603,9 +603,10 @@ function initialize_limit_computations!(ax) lims = calculate_local_limits_from_plots( ax, unwrap_explicit_update(xlims), 1, tf, itf, xautolimitmargin ) + flims = Float64.(lims) # make sure this can reset sharedlimits even if this is reset to the same # value (The update rules have already been applied in the previous step) - return ComputePipeline.ExplicitUpdate(lims, :force) + return ComputePipeline.ExplicitUpdate(flims, :force) end ComputePipeline.set_type!(attr.localxlimits, Union{Tuple{Float64, Float64}, ComputePipeline.ExplicitUpdate{Tuple{Float64, Float64}}}) @@ -617,7 +618,8 @@ function initialize_limit_computations!(ax) lims = calculate_local_limits_from_plots( ax, unwrap_explicit_update(ylims), 2, tf, itf, yautolimitmargin ) - return ComputePipeline.ExplicitUpdate(lims, :force) + flims = Float64.(lims) + return ComputePipeline.ExplicitUpdate(flims, :force) end ComputePipeline.set_type!(attr.localylimits, Union{Tuple{Float64, Float64}, ComputePipeline.ExplicitUpdate{Tuple{Float64, Float64}}}) From 5555cac855e7c48c8f320dee3fd5b5302a1f651a Mon Sep 17 00:00:00 2001 From: ffreyer Date: Fri, 6 Mar 2026 20:04:18 +0100 Subject: [PATCH 28/52] fix type in test --- Makie/test/SceneLike/makielayout.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Makie/test/SceneLike/makielayout.jl b/Makie/test/SceneLike/makielayout.jl index e28f342d11e..7bad2afa62b 100644 --- a/Makie/test/SceneLike/makielayout.jl +++ b/Makie/test/SceneLike/makielayout.jl @@ -65,9 +65,8 @@ end @testset "Axis limits basics" begin f = Figure() ax = Axis(f[1, 1], limits = (nothing, nothing)) - # ax.targetlimits[] = BBox(0, 10, 0, 20) - ax.localxlimits[] = (0, 10) - ax.localylimits[] = (0, 20) + ax.localxlimits[] = (0.0, 10.0) + ax.localylimits[] = (0.0, 20.0) @test ax.finallimits[] == BBox(0, 10, 0, 20) @test ax.limits[] == (nothing, nothing) xlims!(ax, -10, 10) From 2edd52aa55b681b5a46e85d361c437d54f69d430 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sat, 7 Mar 2026 04:15:00 +0100 Subject: [PATCH 29/52] fix some things that were probably wrong --- Makie/src/makielayout/blocks/axis.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 129ffafad1e..631a8b99104 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -576,6 +576,7 @@ function initialize_limit_computations!(ax) # To avoid showing this to the user when fetching ax.limits[] we add another # input here, where (x/y)lims!() can mark which dimension to deny add_input!(attr, :_limit_update_rule, (:force, :force)) + attr.inputs[:_limit_update_rule].force_update = true register_computation!( attr, [:limits, :_limit_update_rule], [:xlimits, :ylimits], @@ -860,7 +861,7 @@ function xlims!(ax::Axis, xlims) update!( ax.attributes, limits = (xlims, ax.limits[][2]), - _limit_update_rule = (:auto, :deny), + _limit_update_rule = (:force, :deny), xreversed = reversed ) @@ -883,7 +884,7 @@ function Makie.ylims!(ax::Axis, ylims) update!( ax.attributes, limits = (ax.limits[][1], ylims), - _limit_update_rule = (:deny, :auto), + _limit_update_rule = (:deny, :force), yreversed = reversed ) From 0bca72dd387c5c1da2e7a9aa722126f1e8d75a50 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sat, 7 Mar 2026 04:15:33 +0100 Subject: [PATCH 30/52] reorder code to skip an Observable --- Makie/src/makielayout/lineaxis.jl | 376 +++++++++++++++--------------- 1 file changed, 191 insertions(+), 185 deletions(-) diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 26832526439..4e4aa51bafc 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -299,150 +299,6 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) on(x -> ComputePipeline.mark_dirty!(dim_convert), obs) end - map!( - calculate_real_ticklabel_align, graph, - [ticklabelalign, :horizontal, flipped, ticklabelrotation], - :realticklabelalign - ) - - add_input!((k, r) -> Rect2f(r), graph, :ticklabelbbox, Rect3d()) - - map!(graph, [:horizontal, :ticklabelbbox], :ticklabel_ideal_space) do horizontal, bbox - maxwidth = horizontal ? height(bbox) : width(bbox) - # not finite until the plot is created - # Note: This used to be `isfinite(maxwidth) && visible` - probably not needed? - return isfinite(maxwidth) ? maxwidth : zero(maxwidth) - end - - register_computation!( - graph, - [:ticklabel_ideal_space, ticklabelspace], - [:actual_ticklabelspace] - ) do (idealspace, space), changed, cached - actual_ticklabelspace = isnothing(cached) ? 0.0f0 : cached[1] - if space == automatic - return (idealspace,) - elseif space isa Symbol - space === :max_auto || error("Invalid ticklabel space $(repr(space)), may be automatic, :max_auto or a real number") - return (max(idealspace, actual_ticklabelspace),) - else - return (space,) - end - end - - map!(graph, [ticksvisible, ticksize, tickalign], :tickspace) do ticksvisible, ticksize, tickalign - return ticksvisible ? max(0.0f0, ticksize * (1.0f0 - tickalign)) : 0.0f0 - end - - map!( - graph, - [spinewidth, :tickspace, ticklabelsvisible, :actual_ticklabelspace, ticklabelpad, labelpadding], - :labelgap - ) do spinewidth, tickspace, ticklabelsvisible, actual_ticklabelspace, ticklabelpad, labelpadding - - return spinewidth + tickspace + - (ticklabelsvisible ? actual_ticklabelspace + ticklabelpad : 0.0f0) + - labelpadding - end - - map!( - graph, - [:position, :extents, :horizontal, flipped, :labelgap], - :labelpos - ) do position, extents, horizontal, flipped, labelgap - # fullgap = tickspace[] + labelgap - middle = extents[1] + 0.5f0 * (extents[2] - extents[1]) - - x_or_y = flipped ? position + labelgap : position - labelgap - - return horizontal ? Point2f(middle, x_or_y) : Point2f(x_or_y, middle) - end - - map!( - graph, - [labelrotation, :horizontal, flipped, flip_vertical_label], - :labelalign - ) do labelrotation, horizontal::Bool, flipped::Bool, flip_vertical_label::Bool - return if labelrotation isa Automatic - if horizontal - (:center, flipped ? :bottom : :top) - else - ( - :center, if flipped - flip_vertical_label ? :bottom : :top - else - flip_vertical_label ? :top : :bottom - end, - ) - end - else - (:center, :center) - end::NTuple{2, Symbol} - end - - - map!( - graph, - [labelrotation, :horizontal, flip_vertical_label], - :labelrot - ) do labelrotation, horizontal::Bool, flip_vertical_label::Bool - return if labelrotation isa Automatic - if horizontal - 0.0f0 - else - (flip_vertical_label ? -0.5f0 : 0.5f0) * π - end - else - Float32(labelrotation) - end::Float32 - end - - - # label + dim convert suffix - map!( - build_label_with_unit_suffix, graph, - [dim_convert, suffix_formatter, label, unit_in_label, use_short_unit], - :label_with_suffix - ) - ComputePipeline.set_type!(graph.label_with_suffix, Any) - - labeltext = text!( - parent, graph.labelpos, text = graph.label_with_suffix, - fontsize = labelsize, color = labelcolor, - visible = labelvisible, - align = graph.labelalign, rotation = graph.labelrot, font = labelfont, - markerspace = :data, inspectable = false - ) - - _labelbbox = register_raw_string_boundingboxes!(labeltext) - add_input!(graph, :labelbbox, Rect2d()) - labelbbox = map(_labelbbox) do bbs - bb = Rect2d(bbs[1]) - update!(graph, :labelbbox => bb) - return bb - end - - # translate axis labels on explicit rotations - # in order to prevent plot and axis overlap - onany( - parent, labelrotation, flipped, graph.horizontal, labelbbox, update = true - ) do labelrotation, flipped, horizontal, bb - xs::Float32, ys::Float32 = if labelrotation isa Automatic - 0.0f0, 0.0f0 - else - wx, wy = widths(bb) - sign::Int = flipped ? 1 : -1 - if horizontal - 0.0f0, Float32(sign * 0.5f0 * wy) - else - Float32(sign * 0.5f0 * wx), 0.0f0 - end - end - translate!(labeltext, xs, ys, 0.0f0) - end - - decorations[:labeltext] = labeltext - map!( graph, # TODO: Why was :pos_extents_horizontal in here? @@ -470,6 +326,24 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) return tickvalues_unfiltered[indices], tickstrings_unfiltered[indices] end + map!( + graph, + [:tickvalues, minorticks, minorticksvisible, minorticksused, scale, limits], + :minortickvalues, + init = Float64[] + ) do values, ticks, visible, used, scale, limits + if visible || used + return get_minor_tickvalues(ticks, scale, values, limits...) + else + return nothing + end + end + ComputePipeline.mark_dirty!(graph.minortickvalues) + + ###################################### + ### Ticks + ###################################### + map!( graph, [:tickvalues, scale, :position, :extents, :horizontal, limits, reversed], @@ -493,35 +367,26 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) end map!( - graph, - [:tickvalues, minorticks, minorticksvisible, minorticksused, scale, limits], - :minortickvalues, - init = Float64[] - ) do values, ticks, visible, used, scale, limits - if visible || used - return get_minor_tickvalues(ticks, scale, values, limits...) - else - return nothing - end - end - ComputePipeline.mark_dirty!(graph.minortickvalues) - - map!( - compute_minor_ticks, graph, - [limits, :position, :extents, :horizontal, :minortickvalues, scale, reversed], - :minortickpositions + calculated_aligned_ticks, graph, + [:horizontal, flipped, :tickpositions, tickalign, ticksize, spinewidth], + :ticksnode ) - map!( - adjust_ticklabel_placement, graph, - [:tickpositions, :horizontal, flipped, spinewidth, :tickspace, ticklabelpad], - :ticklabel_position + ticklines = linesegments!( + parent, graph.ticksnode, linewidth = tickwidth, color = tickcolor, + linestyle = nothing, visible = ticksvisible, inspectable = false ) + decorations[:ticklines] = ticklines + translate!(ticklines, 0, 0, 10) + + ###################################### + ### Minor Ticks + ###################################### map!( - calculated_aligned_ticks, graph, - [:horizontal, flipped, :tickpositions, tickalign, ticksize, spinewidth], - :ticksnode + compute_minor_ticks, graph, + [limits, :position, :extents, :horizontal, :minortickvalues, scale, reversed], + :minortickpositions ) map!( @@ -530,13 +395,6 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) :minorticksnode ) - ticklines = linesegments!( - parent, graph.ticksnode, linewidth = tickwidth, color = tickcolor, - linestyle = nothing, visible = ticksvisible, inspectable = false - ) - decorations[:ticklines] = ticklines - translate!(ticklines, 0, 0, 10) - minorticklines = linesegments!( parent, graph.minorticksnode, linewidth = minortickwidth, color = minortickcolor, linestyle = nothing, visible = minorticksvisible, inspectable = false @@ -544,6 +402,10 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) decorations[:minorticklines] = minorticklines translate!(minorticklines, 0, 0, 10) + ###################################### + ### Axis Line + ###################################### + map!( create_linepoints, graph, [:position, :extents, :horizontal, flipped, spinewidth, trimspine, :tickpositions, tickwidth], @@ -554,15 +416,28 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) parent, graph.linepoints, linewidth = spinewidth, visible = spinevisible, color = spinecolor, inspectable = false, linestyle = nothing ) - translate!(decorations[:axisline], 0, 0, 20) - # trigger whole pipeline once to fill tickpositions and tickstrings - # etc to avoid empty ticks bug #69 - # notify(limits) + ###################################### + ### Tick Labels + ###################################### + + map!(graph, [ticksvisible, ticksize, tickalign], :tickspace) do ticksvisible, ticksize, tickalign + return ticksvisible ? max(0.0f0, ticksize * (1.0f0 - tickalign)) : 0.0f0 + end + + map!( + adjust_ticklabel_placement, graph, + [:tickpositions, :horizontal, flipped, spinewidth, :tickspace, ticklabelpad], + :ticklabel_position + ) + + map!( + calculate_real_ticklabel_align, graph, + [ticklabelalign, :horizontal, flipped, ticklabelrotation], + :realticklabelalign + ) - # in order to dispatch to the correct text recipe later (normal text, latex, etc.) - # we need to have the tickstrings populated once before adding the annotations ticklabels_plot = text!( parent, graph.ticklabel_position, @@ -580,12 +455,143 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) decorations[:ticklabels] = ticklabels_plot ticklabels_bbox = register_raw_string_boundingboxes!(ticklabels_plot) - on(ticklabels_bbox, update = true) do bbs - bb = reduce(update_boundingbox, bbs, init = Rect3f()) - update!(graph, :ticklabelbbox => bb) - return + map!(graph, ticklabels_bbox, :ticklabelbbox) do bbs + return reduce(update_boundingbox, bbs, init = Rect3f()) + end + + ###################################### + ### Axis Labels + ###################################### + + map!(graph, [:horizontal, :ticklabelbbox], :ticklabel_ideal_space) do horizontal, bbox + maxwidth = horizontal ? height(bbox) : width(bbox) + # not finite until the plot is created + # Note: This used to be `isfinite(maxwidth) && visible` - probably not needed? + return isfinite(maxwidth) ? maxwidth : zero(maxwidth) + end + + register_computation!( + graph, + [:ticklabel_ideal_space, ticklabelspace], + [:actual_ticklabelspace] + ) do (idealspace, space), changed, cached + actual_ticklabelspace = isnothing(cached) ? 0.0f0 : cached[1] + if space == automatic + return (idealspace,) + elseif space isa Symbol + space === :max_auto || error("Invalid ticklabel space $(repr(space)), may be automatic, :max_auto or a real number") + return (max(idealspace, actual_ticklabelspace),) + else + return (space,) + end end + map!( + graph, + [labelrotation, :horizontal, flip_vertical_label], + :labelrot + ) do labelrotation, horizontal::Bool, flip_vertical_label::Bool + return if labelrotation isa Automatic + if horizontal + 0.0f0 + else + (flip_vertical_label ? -0.5f0 : 0.5f0) * π + end + else + Float32(labelrotation) + end::Float32 + end + + map!( + graph, + [labelrotation, :horizontal, flipped, flip_vertical_label], + :labelalign + ) do labelrotation, horizontal::Bool, flipped::Bool, flip_vertical_label::Bool + return if labelrotation isa Automatic + if horizontal + (:center, flipped ? :bottom : :top) + else + ( + :center, if flipped + flip_vertical_label ? :bottom : :top + else + flip_vertical_label ? :top : :bottom + end, + ) + end + else + (:center, :center) + end::NTuple{2, Symbol} + end + + map!( + graph, + [spinewidth, :tickspace, ticklabelsvisible, :actual_ticklabelspace, ticklabelpad, labelpadding], + :labelgap + ) do spinewidth, tickspace, ticklabelsvisible, actual_ticklabelspace, ticklabelpad, labelpadding + + return spinewidth + tickspace + + (ticklabelsvisible ? actual_ticklabelspace + ticklabelpad : 0.0f0) + + labelpadding + end + + map!( + graph, + [:position, :extents, :horizontal, flipped, :labelgap], + :labelpos + ) do position, extents, horizontal, flipped, labelgap + # fullgap = tickspace[] + labelgap + middle = extents[1] + 0.5f0 * (extents[2] - extents[1]) + + x_or_y = flipped ? position + labelgap : position - labelgap + + return horizontal ? Point2f(middle, x_or_y) : Point2f(x_or_y, middle) + end + + # label + dim convert suffix + map!( + build_label_with_unit_suffix, graph, + [dim_convert, suffix_formatter, label, unit_in_label, use_short_unit], + :label_with_suffix + ) + ComputePipeline.set_type!(graph.label_with_suffix, Any) + + labeltext = text!( + parent, graph.labelpos, text = graph.label_with_suffix, + fontsize = labelsize, color = labelcolor, + visible = labelvisible, + align = graph.labelalign, rotation = graph.labelrot, font = labelfont, + markerspace = :data, inspectable = false + ) + + _labelbbox = register_raw_string_boundingboxes!(labeltext) + map!(bbs -> Rect2d(bbs[1]), graph, _labelbbox, :labelbbox) + + # translate axis labels on explicit rotations + # in order to prevent plot and axis overlap + onany( + parent, labelrotation, flipped, graph.horizontal, graph.labelbbox, update = true + ) do labelrotation, flipped, horizontal, bb + xs::Float32, ys::Float32 = if labelrotation isa Automatic + 0.0f0, 0.0f0 + else + wx, wy = widths(bb) + sign::Int = flipped ? 1 : -1 + if horizontal + 0.0f0, Float32(sign * 0.5f0 * wy) + else + Float32(sign * 0.5f0 * wx), 0.0f0 + end + end + translate!(labeltext, xs, ys, 0.0f0) + end + + decorations[:labeltext] = labeltext + + ###################################### + ### Protrusions + ###################################### + map!( graph, [labelvisible, :label_with_suffix, :labelbbox, labelpadding, :horizontal], From ebec5aa1e1a3d718eed06d1e02b6a504b04bddff Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sat, 7 Mar 2026 04:20:56 +0100 Subject: [PATCH 31/52] cleanup leftover Observables in LineAxis --- Makie/src/makielayout/blocks/colorbar.jl | 2 +- Makie/src/makielayout/lineaxis.jl | 14 ++++++-------- Makie/src/makielayout/types.jl | 7 +------ 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Makie/src/makielayout/blocks/colorbar.jl b/Makie/src/makielayout/blocks/colorbar.jl index 2b96e8cde75..6fa816021d0 100644 --- a/Makie/src/makielayout/blocks/colorbar.jl +++ b/Makie/src/makielayout/blocks/colorbar.jl @@ -432,7 +432,7 @@ function initialize_block!(cb::Colorbar) cb.axis = axis - onany(blockscene, axis.protrusion, cb.vertical, cb.flipaxis) do axprotrusion, + onany(blockscene, cb.attributes.axis.protrusion, cb.vertical, cb.flipaxis) do axprotrusion, vertical, flipaxis left, right, top, bottom = 0.0f0, 0.0f0, 0.0f0, 0.0f0 diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 4e4aa51bafc..81a0ce6be74 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -623,15 +623,13 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) return needs_gap ? ticklabelspace + pad : 0.0f0 end - map!(+, graph, [:protrusion_labelspace, :protrusion_tickspace, :protrusion_ticklabelgap], :protrusion) - protrusion = ComputePipeline.get_observable!(graph.protrusion) - - # TODO: - tickpositions = ComputePipeline.get_observable!(graph.tickpositions) - minortickpositions = ComputePipeline.get_observable!(graph.minortickpositions) + map!( + +, graph, + [:protrusion_labelspace, :protrusion_tickspace, :protrusion_ticklabelgap], + :protrusion + ) - return LineAxis(parent, protrusion, attrs, decorations, tickpositions, minortickpositions) - # return LineAxis(parent, protrusion, attrs, decorations) + return LineAxis(parent, attrs, graph, decorations) end function tight_ticklabel_spacing!(la::LineAxis) diff --git a/Makie/src/makielayout/types.jl b/Makie/src/makielayout/types.jl index c32025dd2d2..b2bfb4b5c3f 100644 --- a/Makie/src/makielayout/types.jl +++ b/Makie/src/makielayout/types.jl @@ -161,14 +161,9 @@ IntervalsBetween(n) = IntervalsBetween(n, true) mutable struct LineAxis parent::Scene - protrusion::Observable{Float32} attributes::Attributes + graph::ComputePipeline.ComputeGraphView elements::Dict{Symbol, Any} - tickpositions::Observable{Vector{Point2f}} # <-- - # tickvalues::Observable{Vector{Float32}} - # ticklabels::Observable{Vector{Any}} - minortickpositions::Observable{Vector{Point2f}} # <-- - # minortickvalues::Observable{Vector{Float32}} end struct LimitReset end From 13b67747cda9073249d8e7b43359e6d500a499df Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sat, 7 Mar 2026 04:32:00 +0100 Subject: [PATCH 32/52] more LineAxis cleanup, update tight_ticklabel_spacing!() --- Makie/src/makielayout/lineaxis.jl | 65 ++----------------------------- 1 file changed, 4 insertions(+), 61 deletions(-) diff --git a/Makie/src/makielayout/lineaxis.jl b/Makie/src/makielayout/lineaxis.jl index 81a0ce6be74..fc4266311b2 100644 --- a/Makie/src/makielayout/lineaxis.jl +++ b/Makie/src/makielayout/lineaxis.jl @@ -36,39 +36,9 @@ function calculate_horizontal_extends(endpoints)::Tuple{Float32, NTuple{2, Float end end - -function calculate_protrusion( - horizontal, labeltext, ticklabel_position, - ticksvisible::Bool, label, labelvisible::Bool, labelpadding::Number, - tickspace::Number, ticklabelsvisible::Bool, - actual_ticklabelspace::Number, ticklabelpad::Number, _... - ) - - label_is_empty::Bool = iswhitespace(label) - - real_labelsize::Float32 = if label_is_empty - 0.0f0 - else - # TODO: This can probably be - # widths(fast_string_boundingboxes(labeltext)[1]) - # to skip positions? (This only runs for axis labels) - widths(boundingbox(labeltext, :data))[horizontal[] ? 2 : 1] - end - - labelspace::Float32 = (labelvisible && !label_is_empty) ? real_labelsize + labelpadding : 0.0f0 - - _tickspace::Float32 = (ticksvisible && !isempty(ticklabel_position[])) ? tickspace : 0.0f0 - - needs_gap = (ticklabelsvisible && actual_ticklabelspace > 0) - ticklabelgap::Float32 = needs_gap ? actual_ticklabelspace + ticklabelpad : 0.0f0 - - return _tickspace + ticklabelgap + labelspace -end - - function create_linepoints( position::Float32, extents::NTuple{2, Float32}, horizontal::Bool, - flipped::Bool, spine_width::Number, trimspine::Union{Bool, Tuple{Bool, Bool}}, + spine_width::Number, trimspine::Union{Bool, Tuple{Bool, Bool}}, tickpositions::Vector{Point2f}, tickwidth::Number ) @@ -224,17 +194,6 @@ function build_label_with_unit_suffix(dim_convert, formatter, label, show_unit_i end end -macro make_computed(graph, key) - return quote - if !haskey($(esc(graph)), $(QuoteNode(key))) - # if !isa($(esc(key)), Computed) - # println("Added: ", $(QuoteNode(key)), "::", typeof($(esc(key)))) - # end - add_input!($(esc(graph)), $(QuoteNode(key)), $(esc(key))) - end - end -end - function _extract_computed(graph::ComputePipeline.AbstractComputeGraph, dictlike, name) entry = dictlike[name] root = ComputePipeline.root(graph) @@ -408,7 +367,7 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) map!( create_linepoints, graph, - [:position, :extents, :horizontal, flipped, spinewidth, trimspine, :tickpositions, tickwidth], + [:position, :extents, :horizontal, spinewidth, trimspine, :tickpositions, tickwidth], :linepoints ) @@ -633,24 +592,8 @@ function LineAxis(parent::Scene, graph::AbstractComputeGraph, attrs::Attributes) end function tight_ticklabel_spacing!(la::LineAxis) - - horizontal = if la.attributes.endpoints[][1][2] == la.attributes.endpoints[][2][2] - true - elseif la.attributes.endpoints[][1][1] == la.attributes.endpoints[][2][1] - false - else - error("endpoints not on a horizontal or vertical line") - end - - tls = la.elements[:ticklabels] - maxwidth = if horizontal - # height - tls.visible[] ? height(Rect2f(boundingbox(tls, :data))) : 0.0f0 - else - # width - tls.visible[] ? width(Rect2f(boundingbox(tls, :data))) : 0.0f0 - end - la.attributes.ticklabelspace = maxwidth + maxwidth = la.graph.ticklabel_ideal_space[] + la.attributes.ticklabelspace[] = maxwidth return Float64(maxwidth) end From 1d61ed78e73de868ee4c326c9c517a4034fad2b7 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sat, 7 Mar 2026 19:03:29 +0100 Subject: [PATCH 33/52] avoid checking boundingboxes for each dimension --- Makie/src/makielayout/blocks/axis.jl | 172 ++++++++++----------------- 1 file changed, 64 insertions(+), 108 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 631a8b99104..7a16aa63803 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -579,49 +579,22 @@ function initialize_limit_computations!(ax) attr.inputs[:_limit_update_rule].force_update = true register_computation!( - attr, [:limits, :_limit_update_rule], [:xlimits, :ylimits], - ) do (limits, rule), changed, cached + attr, + [:limits, :_limit_update_rule, :transform_func, :inverse_transform_func, :xautolimitmargin, :yautolimitmargin], + [:localxlimits, :localylimits], + ) do (limits, rule, tf, itf, xmargins, ymargins), changed, cached + lims = calculate_local_limits(ax, limits, tf, itf, xmargins, ymargins) if changed._limit_update_rule # The update comes from (x/y)lims!() which explicitly set update rules - xlims = ComputePipeline.ExplicitUpdate(limits[1], rule[1]) - ylims = ComputePipeline.ExplicitUpdate(limits[2], rule[2]) + xlims = ComputePipeline.ExplicitUpdate(lims[1], rule[1]) + ylims = ComputePipeline.ExplicitUpdate(lims[2], rule[2]) return xlims, ylims else # force propagation of nothing, compare for numbers - x = make_limit_update_explit.(limits) - return x + return ComputePipeline.ExplicitUpdate.(lims, :force) end end - ComputePipeline.set_type!(attr.xlimits, Any) - ComputePipeline.set_type!(attr.ylimits, Any) - - - map!( - attr, - [:xlimits, :transform_func, :inverse_transform_func, :xautolimitmargin], - :localxlimits - ) do xlims, tf, itf, xautolimitmargin - lims = calculate_local_limits_from_plots( - ax, unwrap_explicit_update(xlims), 1, tf, itf, xautolimitmargin - ) - flims = Float64.(lims) - # make sure this can reset sharedlimits even if this is reset to the same - # value (The update rules have already been applied in the previous step) - return ComputePipeline.ExplicitUpdate(flims, :force) - end ComputePipeline.set_type!(attr.localxlimits, Union{Tuple{Float64, Float64}, ComputePipeline.ExplicitUpdate{Tuple{Float64, Float64}}}) - - map!( - attr, - [:ylimits, :transform_func, :inverse_transform_func, :yautolimitmargin], - :localylimits - ) do ylims, tf, itf, yautolimitmargin - lims = calculate_local_limits_from_plots( - ax, unwrap_explicit_update(ylims), 2, tf, itf, yautolimitmargin - ) - flims = Float64.(lims) - return ComputePipeline.ExplicitUpdate(flims, :force) - end ComputePipeline.set_type!(attr.localylimits, Union{Tuple{Float64, Float64}, ComputePipeline.ExplicitUpdate{Tuple{Float64, Float64}}}) setfield!(ax, :xaxislinks, Axis[]) @@ -719,100 +692,83 @@ function initialize_limit_computations!(ax) return end -function getlimits(ax::Axis, dim, tf = ax.scene.transform_func, itf = inverse_transform(tf)) - # find all plots that don't have exclusion attributes set - # for this dimension - if !(dim in (1, 2)) - error("Dimension $dim not allowed. Only 1 or 2.") - end - - function exclude(plot) - # only use plots with autolimits = true - to_value(get(plot, dim == 1 ? :xautolimits : :yautolimits, true)) || return true - # only if they use data coordinates - is_data_space(plot) || return true - # only use visible plots for limits - return !to_value(get(plot, :visible, true)) - end +function get_limits(ax::Axis, tf, itf) + x0, x1, y0, y1 = (Inf, -Inf, Inf, -Inf) + for plot in ax.scene.plots + update_x = to_value(get(plot, :xautolimits, true)) + update_y = to_value(get(plot, :yautolimits, true)) + if is_data_space(plot) && to_value(get(plot, :visible, true)) && (update_x || update_y) + if (itf === nothing) || (itf === :nothing) + @warn "Axis transformation $tf does not define an `inverse_transform()`. This may result in a bad choice of limits due to model transformations being ignored." maxlog = 1 + mini, maxi = extrema(Rect2d(data_limits(ax))) + else + # get limits with transform_func and model applied + bb = boundingbox(plot) + # then undo transform_func so that ticks can handle transform_func + # without ignoring translations, scaling or rotations from model + try + bb = apply_transform(itf, bb) + catch e + @warn "Failed to apply inverse transform $itf to bounding box $bb. Falling back on data_limits()." exception = e + bb = data_limits(ax.scene, exclude) + end + mini, maxi = extrema(Rect2d(bb)) + end - # get all data limits, without the excluded plots - if (itf === nothing) || (itf === :nothing) - @warn "Axis transformation $tf does not define an `inverse_transform()`. This may result in a bad choice of limits due to model transformations being ignored." maxlog = 1 - bb = data_limits(ax.scene, exclude) - else - # get limits with transform_func and model applied - bb = boundingbox(ax.scene, exclude) - # then undo transform_func so that ticks can handle transform_func - # without ignoring translations, scaling or rotations from model - try - bb = apply_transform(itf, bb) - catch e - @warn "Failed to apply inverse transform $itf to bounding box $bb. Falling back on data_limits()." exception = e - bb = data_limits(ax.scene, exclude) + x0 = ifelse(update_x && isfinite(mini[1]), min(x0, mini[1]), x0) + x1 = ifelse(update_x && isfinite(maxi[1]), max(x1, maxi[1]), x1) + y0 = ifelse(update_y && isfinite(mini[2]), min(y0, mini[2]), y0) + y1 = ifelse(update_y && isfinite(maxi[2]), max(y1, maxi[2]), y1) end end - - # if there are no bboxes remaining, `nothing` signals that no limits could be determined - isfinite_rect(bb, dim) || return nothing - - # otherwise start with the first box - mini, maxi = minimum(bb), maximum(bb) - return (mini[dim], maxi[dim]) + return (x0, x1), (y0, y1) end function autolimits( - ax::Axis, dim::Integer, + ax::Axis, tf = ax.scene.transform_func, itf = inverse_transform(tf), - margin = ax.attributes[(:xautolimitmargin, :yautolimitmargin)[dim]][] + xmargin = ax.xautolimitmargin, ymargin = ax.yautolimitmargin ) # try getting x limits for the axis and then union them with linked axes - lims = getlimits(ax, dim, tf, itf) + xlims, ylims = get_limits(ax, tf, itf) - if isnothing(lims) - return defaultlimits(tf[dim]) + xlims = if xlims[1] < xlims[2] + expandlimits(xlims, xmargin..., tf[1]) else - return expandlimits(lims, margin[1], margin[2], tf[dim]) + defaultlimits(tf[1]) end -end -# TODO: Is this supposed to be public api? -# autolimits is quite different now. may return nothing, no linking, no validate -# xautolimits(ax::Axis = current_axis()) = autolimits(ax, 1) -# yautolimits(ax::Axis = current_axis()) = autolimits(ax, 2) - -# Basically `reset_limits!()` without x/y/zauto = false -function calculate_local_limits_from_plots(ax, user_limits, idx, tf, itf, margin) - lims = if isnothing(user_limits) || user_limits[1] === nothing || user_limits[2] === nothing - l = autolimits(ax, idx, tf, itf, margin) - if user_limits === nothing - l - else - lo = user_limits[1] === nothing ? l[1] : user_limits[1] - hi = user_limits[2] === nothing ? l[2] : user_limits[2] - (lo, hi) - end + ylims = if ylims[1] < ylims[2] + expandlimits(ylims, ymargin..., tf[2]) else - convert(Tuple{Float64, Float64}, tuple(user_limits...)) + defaultlimits(tf[2]) end - # Could not determine limits from plots, so discard the update by returning nothing - isnothing(lims) && return lims + return xlims, ylims +end + +function calculate_local_limits(ax, (user_xlims, user_ylims), tf, itf, xmargins, ymargins) + # Skip boundingbox calls (in autolimits -> getlimits) if user provides full limits + if isnothing(user_xlims) || isnothing(user_ylims) || any(isnothing, user_xlims) || any(isnothing, user_ylims) + auto_xlims, auto_ylims = autolimits(ax, tf, itf, xmargins, ymargins) - if !(lims[1] <= lims[2]) - dim = (:x, :y, :z)[idx] - error("Invalid $dim-limits as $(dim)lims[1] <= $(dim)lims[2] is not met for $lims.") - end + xlims = if isnothing(user_xlims) + auto_xlims + else + something.(user_xlims, auto_xlims) + end - return lims -end + ylims = if isnothing(user_ylims) + auto_ylims + else + something.(user_ylims, auto_ylims) + end -function calculate_local_limits(ax, user_limits, tf, itf) - lims = map(user_limits, eachindex(user_limits)) do lims, idx - calculate_local_limits(ax, lims, idx, tf, itf) + return Float64.(xlims), Float64.(ylims) + else + return Float64.(user_xlims), Float64.(user_ylims) end - bb = Rect(Vecf(first.(lims)), Vecf(last.(lims) .- first.(lims))) - return bb end function adjustlimits(limits, autolimitaspect, viewport, xautolimitmargin, yautolimitmargin) From b191501edd360cd2c7f83700c4f9b1e09376ee01 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 01:00:26 +0100 Subject: [PATCH 34/52] fix 0 width limits limits --- Makie/src/makielayout/blocks/axis.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 7a16aa63803..5cd60913ffd 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -732,13 +732,13 @@ function autolimits( # try getting x limits for the axis and then union them with linked axes xlims, ylims = get_limits(ax, tf, itf) - xlims = if xlims[1] < xlims[2] + xlims = if xlims[1] <= xlims[2] expandlimits(xlims, xmargin..., tf[1]) else defaultlimits(tf[1]) end - ylims = if ylims[1] < ylims[2] + ylims = if ylims[1] <= ylims[2] expandlimits(ylims, ymargin..., tf[2]) else defaultlimits(tf[2]) From fed3e3ba0904c1b224b9fd98f8f18227fa64a843 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 02:16:57 +0100 Subject: [PATCH 35/52] allow mark_dirty!() to exit early by having mark_resolved!() resolve parents --- ComputePipeline/src/ComputePipeline.jl | 57 +++++++++++++++++--------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index b2bb343c9ca..75effc0b059 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -546,11 +546,19 @@ end isdirty(edge::ComputeEdge) = !edge.got_resolved[] -# Note: -# GLMakie may mark an unresolved renderobject as resolved to avoid repeated -# errors from repeatedly pulling it. This requires us to not shortcut mark_dirty!() -# Without that, we should be able to skip mark_dirty for any child/dependent that -# is already dirty +# [Rules]: +# after mark_dirty!(): +# any input dirty => all outputs dirty +# after resolve!(): any output +# any output resolved => all inputs resolved +# <=> !(all outputs dirty) => !(any input dirty) +# => any input dirty => all outputs dirty +# edge.got_resolved[] encodes this: +# resolve!() sets it to true when resolving all edge inputs & outputs +# mark_dirty!() sets it to false to mark all edge outputs dirty +# As long as every other action preserves these rules we can: +# 1. stop resolving inputs when edge.got_resolved[] == true +# 2. stop mark_dirty!() when edge.got_resolved[] == false """ mark_resolved!(computed) @@ -563,20 +571,29 @@ function mark_resolved!(computed::Computed) hasparent(computed) && mark_resolved!(computed.parent) return end -mark_resolved!(edge::ComputeEdge) = edge.got_resolved[] = true -mark_resolved!(edge::Input) = edge.is_dirty = true +function mark_resolved!(edge::ComputeEdge) + if !edge.got_resolved[] + edge.got_resolved[] = true + # Follow the [Rules]: + foreach(resolve!, edge.inputs) + end + return +end +mark_resolved!(edge::Input) = edge.dirty = true function mark_dirty!(edge::ComputeEdge, obs_to_update::Vector{Observable}) - # Assumes this is the same graph as edge.outputs (for parent -> child graph edges) - g = edge.graph - for output in edge.outputs - push!(g.onchange.val, output.name) - g.onchange in obs_to_update || push!(obs_to_update, g.onchange) - end + if true # edge.got_resolved[] # because of [Rules] + # Assumes this is the same graph as edge.outputs (for parent -> child graph edges) + g = edge.graph + for output in edge.outputs + push!(g.onchange.val, output.name) + g.onchange in obs_to_update || push!(obs_to_update, g.onchange) + end - edge.got_resolved[] = false - for dep in edge.dependents - mark_dirty!(dep, obs_to_update) + edge.got_resolved[] = false + for dep in edge.dependents + mark_dirty!(dep, obs_to_update) + end end return end @@ -930,8 +947,10 @@ isdirty(input::Input) = input.dirty Base.getindex(computed::Computed) = resolve!(computed) +# After resolving a parent edge, inform the dependent edge about the nodes the +# parent updated. function mark_input_dirty!(parent::ComputeEdge, edge::ComputeEdge) - @assert parent.got_resolved[] # parent should only call this after resolve! + @assert parent.got_resolved[] for i in eachindex(edge.inputs) edge.inputs_dirty[i] |= getfield(edge.inputs[i], :dirty) end @@ -1023,7 +1042,7 @@ function _resolve!(computed::Computed) end function resolve!(edge::ComputeEdge) - isdirty(edge) || return false + isdirty(edge) || return false # because of [Rules] return lock(edge.graph.lock) do # Resolve inputs first foreach(_resolve!, edge.inputs) @@ -1982,7 +2001,7 @@ function unsafe_init!(edge::ComputeEdge) end return lock(edge.graph.lock) do - # Resolve inputs first + # Follow the [Rules]: foreach(_resolve!, edge.inputs) edge.typed_edge[] = TypedEdge_no_call(edge) edge.got_resolved[] = true From 1e50bf1699cc5e3627bca8bb12c7aa8e49a972c4 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 04:09:02 +0100 Subject: [PATCH 36/52] avoid recursive lock, use `@lock` --- ComputePipeline/src/ComputePipeline.jl | 69 +++++++++++++++----------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index 75effc0b059..3806d0ef823 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -605,6 +605,12 @@ function mark_dirty!(computed::Computed) end function resolve!(input::Input) + return @lock input.graph.lock begin + locked_resolve!(input) + end +end + +function locked_resolve!(input::Input) input.dirty || return value = input.f(input.value) if isdefined(input.output, :value) && isassigned(input.output.value) @@ -1003,7 +1009,7 @@ end # do we want this type stable? # This is how we could get a type stable callback body for resolve -function resolve!(edge::TypedEdge) +function locked_resolve!(edge::TypedEdge) if any(edge.inputs_dirty) # only call if inputs changed dirty = _get_named_change(edge.inputs, edge.inputs_dirty) vals = map(getindex, edge.outputs) @@ -1026,39 +1032,46 @@ function resolve!(edge::TypedEdge) return end +function locked_resolve!(computed::Computed) + if hasparent(computed) + locked_resolve!(computed.parent) + end + return +end + +function locked_resolve!(edge::ComputeEdge) + isdirty(edge) || return false # because of [Rules] + # Resolve inputs first + foreach(locked_resolve!, edge.inputs) + if !isassigned(edge.typed_edge) + # constructor does first resolve to determine fully typed outputs + edge.typed_edge[] = TypedEdge(edge) + else + locked_resolve!(edge.typed_edge[]) + end + edge.got_resolved[] = true + fill!(edge.inputs_dirty, false) + for dep in edge.dependents + mark_input_dirty!(edge, dep) + end + foreach(comp -> comp.dirty = false, edge.outputs) + return true +end + function resolve!(computed::Computed) try - return _resolve!(computed) + if hasparent(computed) + resolve!(computed.parent) + end + return computed.value[] catch e rethrow(ResolveException(computed, e)) end end -function _resolve!(computed::Computed) - if hasparent(computed) - resolve!(computed.parent) - end - return computed.value[] -end - function resolve!(edge::ComputeEdge) - isdirty(edge) || return false # because of [Rules] - return lock(edge.graph.lock) do - # Resolve inputs first - foreach(_resolve!, edge.inputs) - if !isassigned(edge.typed_edge) - # constructor does first resolve to determine fully typed outputs - edge.typed_edge[] = TypedEdge(edge) - else - resolve!(edge.typed_edge[]) - end - edge.got_resolved[] = true - fill!(edge.inputs_dirty, false) - for dep in edge.dependents - mark_input_dirty!(edge, dep) - end - foreach(comp -> comp.dirty = false, edge.outputs) - return true + return @lock edge.graph.lock begin + locked_resolve!(edge) end end @@ -1215,7 +1228,7 @@ function TypedEdge(edge::ComputeEdge, f::typeof(compute_identity), inputs) return TypedEdge(f, inputs, edge.inputs_dirty, inputs, edge.outputs) end -function resolve!(edge::TypedEdge{IT, OT, typeof(compute_identity)}) where {IT, OT} +function locked_resolve!(edge::TypedEdge{IT, OT, typeof(compute_identity)}) where {IT, OT} # outputs are identical to inputs, so just copy the input state. To be safe # don't overwrite any `dirty = true` state with false (maybe a problem if # the input gets resolved?) @@ -2002,7 +2015,7 @@ function unsafe_init!(edge::ComputeEdge) return lock(edge.graph.lock) do # Follow the [Rules]: - foreach(_resolve!, edge.inputs) + foreach(locked_resolve!, edge.inputs) edge.typed_edge[] = TypedEdge_no_call(edge) edge.got_resolved[] = true fill!(edge.inputs_dirty, false) From 06633dbb8c9685df31d1e988edc3e2b771db9be7 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 15:33:26 +0100 Subject: [PATCH 37/52] move old reset_limits to Axis3 --- Makie/src/makielayout/blocks/axis.jl | 116 ------------------------- Makie/src/makielayout/blocks/axis3d.jl | 83 ++++++++++++++++++ 2 files changed, 83 insertions(+), 116 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 5cd60913ffd..300e59a767a 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -670,25 +670,6 @@ function initialize_limit_computations!(ax) idm = Makie.Mat4f(Makie.I) on(proj -> Makie.set_proj_view!(ax.scene.camera, proj, idm), attr.projectionmatrix, update = true) - #= - limits normalize structure, entrypoint, xlims!(), ylims!(), - | | reset_limits!(), autolimits!(), LimitReset - ↓ ↓ - xlimits ylimits unwrap, force nothing propagation - ↓ ↓ - localxlimits localylimits mix in plot based limits, validation, target for interactions - ↓ ↓ - sharedxlimits sharedylimits updates linked axes sharedlimits as a side effect - ↓ ↓ |- either is a source for interactions - targetlimits to Rect2d - ↓ - viewport → finallimits → viewport aspect, margins - ↓ - projectionmatrix update f32convert, calculate camera matrix - ↓ - camera observables - =# - return end @@ -915,103 +896,6 @@ function mirror_ticks(tickpositions, ticksize, tickalign, viewport, side, axispo return points end -# TODO: This is not relevant to Axis anymore -""" - reset_limits!(ax; xauto = true, yauto = true) - -Resets the axis limits depending on the value of `ax.limits`. -If one of the two components of limits is nothing, -that value is either copied from the targetlimits if `xauto` or `yauto` is false, -respectively, or it is determined automatically from the plots in the axis. -If one of the components is a tuple of two numbers, those are used directly. -""" -function reset_limits!(ax; xauto = true, yauto = true, zauto = true) - mlims = convert_limit_attribute(ax.limits[]) - - if ax isa Axis - mxlims, mylims = mlims::Tuple{Any, Any} - elseif ax isa Axis3 - mxlims, mylims, mzlims = mlims::Tuple{Any, Any, Any} - else - error() - end - - xlims = if isnothing(mxlims) || mxlims[1] === nothing || mxlims[2] === nothing - l = if xauto - xautolimits(ax) - else - minimum(ax.targetlimits[])[1], maximum(ax.targetlimits[])[1] - end - if mxlims === nothing - l - else - lo = mxlims[1] === nothing ? l[1] : mxlims[1] - hi = mxlims[2] === nothing ? l[2] : mxlims[2] - (lo, hi) - end - else - convert(Tuple{Float64, Float64}, tuple(mxlims...)) - end - ylims = if isnothing(mylims) || mylims[1] === nothing || mylims[2] === nothing - l = if yauto - yautolimits(ax) - else - minimum(ax.targetlimits[])[2], maximum(ax.targetlimits[])[2] - end - if mylims === nothing - l - else - lo = mylims[1] === nothing ? l[1] : mylims[1] - hi = mylims[2] === nothing ? l[2] : mylims[2] - (lo, hi) - end - else - convert(Tuple{Float64, Float64}, tuple(mylims...)) - end - - if ax isa Axis3 - zlims = if isnothing(mzlims) || mzlims[1] === nothing || mzlims[2] === nothing - l = if zauto - zautolimits(ax) - else - minimum(ax.targetlimits[])[3], maximum(ax.targetlimits[])[3] - end - if mzlims === nothing - l - else - lo = mzlims[1] === nothing ? l[1] : mzlims[1] - hi = mzlims[2] === nothing ? l[2] : mzlims[2] - (lo, hi) - end - else - convert(Tuple{Float32, Float32}, tuple(mzlims...)) - end - end - - if !(xlims[1] <= xlims[2]) - error("Invalid x-limits as xlims[1] <= xlims[2] is not met for $xlims.") - end - if !(ylims[1] <= ylims[2]) - error("Invalid y-limits as ylims[1] <= ylims[2] is not met for $ylims.") - end - if ax isa Axis3 - if !(zlims[1] <= zlims[2]) - error("Invalid z-limits as zlims[1] <= zlims[2] is not met for $zlims.") - end - end - - tlims = if ax isa Axis - BBox(xlims..., ylims...) - elseif ax isa Axis3 - Rect3f( - Vec3f(xlims[1], ylims[1], zlims[1]), - Vec3f(xlims[2] - xlims[1], ylims[2] - ylims[1], zlims[2] - zlims[1]), - ) - end - ax.targetlimits[] = tlims - return nothing -end - # this is so users can do limits = (left, right, bottom, top) function convert_limit_attribute(lims::Tuple{Any, Any, Any, Any}) return (lims[1:2], lims[3:4]) diff --git a/Makie/src/makielayout/blocks/axis3d.jl b/Makie/src/makielayout/blocks/axis3d.jl index f3164944694..dd55c944753 100644 --- a/Makie/src/makielayout/blocks/axis3d.jl +++ b/Makie/src/makielayout/blocks/axis3d.jl @@ -1020,6 +1020,89 @@ function convert_limit_attribute(lims::Tuple{Any, Any, Any}) end +""" + reset_limits!(ax; xauto = true, yauto = true) + +Resets the axis limits depending on the value of `ax.limits`. +If one of the two components of limits is nothing, +that value is either copied from the targetlimits if `xauto` or `yauto` is false, +respectively, or it is determined automatically from the plots in the axis. +If one of the components is a tuple of two numbers, those are used directly. +""" +function reset_limits!(ax::Axis3; xauto = true, yauto = true, zauto = true) + mlims = convert_limit_attribute(ax.limits[]) + + mxlims, mylims, mzlims = mlims::Tuple{Any, Any, Any} + + xlims = if isnothing(mxlims) || mxlims[1] === nothing || mxlims[2] === nothing + l = if xauto + xautolimits(ax) + else + minimum(ax.targetlimits[])[1], maximum(ax.targetlimits[])[1] + end + if mxlims === nothing + l + else + lo = mxlims[1] === nothing ? l[1] : mxlims[1] + hi = mxlims[2] === nothing ? l[2] : mxlims[2] + (lo, hi) + end + else + convert(Tuple{Float64, Float64}, tuple(mxlims...)) + end + ylims = if isnothing(mylims) || mylims[1] === nothing || mylims[2] === nothing + l = if yauto + yautolimits(ax) + else + minimum(ax.targetlimits[])[2], maximum(ax.targetlimits[])[2] + end + if mylims === nothing + l + else + lo = mylims[1] === nothing ? l[1] : mylims[1] + hi = mylims[2] === nothing ? l[2] : mylims[2] + (lo, hi) + end + else + convert(Tuple{Float64, Float64}, tuple(mylims...)) + end + + zlims = if isnothing(mzlims) || mzlims[1] === nothing || mzlims[2] === nothing + l = if zauto + zautolimits(ax) + else + minimum(ax.targetlimits[])[3], maximum(ax.targetlimits[])[3] + end + if mzlims === nothing + l + else + lo = mzlims[1] === nothing ? l[1] : mzlims[1] + hi = mzlims[2] === nothing ? l[2] : mzlims[2] + (lo, hi) + end + else + convert(Tuple{Float32, Float32}, tuple(mzlims...)) + end + + if !(xlims[1] <= xlims[2]) + error("Invalid x-limits as xlims[1] <= xlims[2] is not met for $xlims.") + end + if !(ylims[1] <= ylims[2]) + error("Invalid y-limits as ylims[1] <= ylims[2] is not met for $ylims.") + end + if !(zlims[1] <= zlims[2]) + error("Invalid z-limits as zlims[1] <= zlims[2] is not met for $zlims.") + end + + tlims = + Rect3f( + Vec3f(xlims[1], ylims[1], zlims[1]), + Vec3f(xlims[2] - xlims[1], ylims[2] - ylims[1], zlims[2] - zlims[1]), + ) + ax.targetlimits[] = tlims + return nothing +end + function xautolimits(ax::Axis3) xlims = getlimits(ax, 1) From 35e67166633f0d38538c3f1a7a0be4ab7b6d72c7 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 15:34:02 +0100 Subject: [PATCH 38/52] fix autolimits, same value reset in Axis3 --- Makie/src/makielayout/blocks/axis3d.jl | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Makie/src/makielayout/blocks/axis3d.jl b/Makie/src/makielayout/blocks/axis3d.jl index dd55c944753..e958a45de7c 100644 --- a/Makie/src/makielayout/blocks/axis3d.jl +++ b/Makie/src/makielayout/blocks/axis3d.jl @@ -1,5 +1,14 @@ struct Axis3Camera <: AbstractCamera end +function add_attributes!(T::Type{<:Axis3}, graph, attributes) + limits = pop!(attributes, :limits) + add_input!(graph, :limits, limits) + ComputePipeline.set_type!(graph.limits, Any) + graph.inputs[:limits].force_update = true + _add_attributes!(T, graph, attributes) + return +end + function initialize_block!(ax::Axis3) blockscene = ax.blockscene @@ -262,7 +271,8 @@ function initialize_block!(ax::Axis3) ax.interactions = Dict{Symbol, Tuple{Bool, Any}}() - on(scene, ax.limits, update = true) do lims + limits_obs = ComputePipeline.get_observable!(ax.attributes, :limits, use_deepcopy = false) + on(scene, limits_obs, update = true) do lims reset_limits!(ax) end From 73d9e1ddcd3db52e99f03ad729a3edf0f8787684 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 15:46:31 +0100 Subject: [PATCH 39/52] fix `set_type!()` for inputs --- ComputePipeline/src/ComputePipeline.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index 3806d0ef823..fedc1a67b7b 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -613,7 +613,7 @@ end function locked_resolve!(input::Input) input.dirty || return value = input.f(input.value) - if isdefined(input.output, :value) && isassigned(input.output.value) + if isdefined(input.output, :value) input.output.value[] = deref(value) else input.output.value = value isa RefValue ? value : RefValue(value) From 5fe3825509349a6c0da0459ea38d8ca3f987cee0 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 15:47:04 +0100 Subject: [PATCH 40/52] fix PolarAxis autolimits/same value limit resets --- Makie/src/makielayout/blocks/polaraxis.jl | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Makie/src/makielayout/blocks/polaraxis.jl b/Makie/src/makielayout/blocks/polaraxis.jl index c1e93b8d5df..bbb7a30b730 100644 --- a/Makie/src/makielayout/blocks/polaraxis.jl +++ b/Makie/src/makielayout/blocks/polaraxis.jl @@ -1,3 +1,18 @@ +function add_attributes!(T::Type{<:PolarAxis}, graph, attributes) + rlimits = pop!(attributes, :rlimits) + add_input!(graph, :rlimits, rlimits) + ComputePipeline.set_type!(graph.rlimits, Any) + graph.inputs[:rlimits].force_update = true + + thetalimits = pop!(attributes, :thetalimits) + add_input!(graph, :thetalimits, thetalimits) + ComputePipeline.set_type!(graph.thetalimits, Any) + graph.inputs[:thetalimits].force_update = true + + _add_attributes!(T, graph, attributes) + return +end + ################################################################################ ### Main Block Initialization ################################################################################ @@ -218,7 +233,9 @@ function setup_camera_matrices!(po::PolarAxis) setfield!(po, :target_theta_0, map(Float32, po.theta_0)) setfield!(po, :target_r0, Observable{Float32}(po.radius_at_origin[] isa Real ? po.radius_at_origin[] : 0.0f0)) reset_limits!(po) - onany((_, _) -> reset_limits!(po), po.blockscene, po.rlimits, po.thetalimits) + rlimits_obs = ComputePipeline.get_observable!(po.attributes, :rlimits, use_deepcopy = false) + thetalimits_obs = ComputePipeline.get_observable!(po.attributes, :thetalimits, use_deepcopy = false) + onany((_, _) -> reset_limits!(po), po.blockscene, rlimits_obs, thetalimits_obs) # get cartesian bbox defined by axis limits data_bbox = map( From 1ba00142e7dd5e784a7b37ed34f794cb27419db4 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 15:48:05 +0100 Subject: [PATCH 41/52] minor cleanup --- Makie/src/makielayout/blocks/axis.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 300e59a767a..d2f2d4b5408 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -527,8 +527,8 @@ end function add_attributes!(T::Type{<:Axis}, graph, attributes) limits = pop!(attributes, :limits) - add_input!((k, v) -> Ref{Any}(convert_limit_attribute(v)), graph, :limits, limits) - # ComputePipeline.set_type!(graph.limits, Any) TODO: + add_input!((k, v) -> convert_limit_attribute(v), graph, :limits, limits) + ComputePipeline.set_type!(graph.limits, Any) _add_attributes!(T, graph, attributes) return end From d2bac7ff73073861042080ffa2168323c6ee50ee Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 16:47:10 +0100 Subject: [PATCH 42/52] benchmark against breaking --- metrics/ttfp/run-benchmark.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metrics/ttfp/run-benchmark.jl b/metrics/ttfp/run-benchmark.jl index 94cb4418741..deacbdc4011 100644 --- a/metrics/ttfp/run-benchmark.jl +++ b/metrics/ttfp/run-benchmark.jl @@ -15,7 +15,7 @@ using Statistics: median Package = length(ARGS) > 0 ? ARGS[1] : "CairoMakie" n_samples = length(ARGS) > 1 ? parse(Int, ARGS[2]) : 7 # base_branch = length(ARGS) > 2 ? ARGS[3] : "master" -base_branch = "master" +base_branch = "ff/breaking-0.25" # Package = "CairoMakie" # n_samples = 2 From b3330cee3c04af2dbcdab17e8d352061c04421b1 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 17:29:18 +0100 Subject: [PATCH 43/52] convenience + docstrings for forced_update and ExplicitUpdate --- ComputePipeline/src/ComputePipeline.jl | 39 +++++++++++++++++++++++--- ComputePipeline/src/utils.jl | 13 +++++++++ Makie/src/makielayout/blocks/axis.jl | 3 -- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index fedc1a67b7b..c08226ca03e 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -895,7 +895,7 @@ function Base.getproperty(attr::ComputeGraphView, key::Symbol) return getindex(attr, key) end -function Base.getindex(attr::ComputeGraphView, key1::Symbol, key2::Symbol, keys::Symbol...) +function Base.getindex(attr::AbstractComputeGraph, key1::Symbol, key2::Symbol, keys::Symbol...) return getindex(getindex(attr, key1), key2, keys...) end @@ -1110,7 +1110,34 @@ add_input! # add_input!([func, ], attr, key, val) handles value based processing # _add_input!(func, attr, key, val) handles node insertion -add_input!(attr::ComputeGraph, args...) = add_input!(attr, Base.front(args), last(args)) +# Since attr.input return the Computed node after the Input it's convenient to +# have this work with Computed +""" + enable_forced_updates!(input) + +Sets `input.forced_update = true` which makes the `Input` propagate same value +updates to the `Computed` node they connect to. To further propagate same +value updates through `ComputeEdge`s, wrap the values to propagate in +`ExplicitUpdate(value, :force)`. +""" +function enable_forced_updates!(node::Computed) + input = node.parent + input isa Input || error("Forced updates are only implemented for Inputs. Use ExplicitUpdate(data, :force) otherwise.") + enable_forced_updates!(input) + return +end +function enable_forced_updates!(input::Input) + input.force_update = true + return +end + +function add_input!(attr::ComputeGraph, args...; force_update = false) + add_input!(attr, Base.front(args), last(args)) + if force_update + enable_forced_updates!(getindex(attr, Base.front(args)...).parent) + end + return attr +end function add_input!(attr::ComputeGraphView, args...) combined = (attr.nested_trace.keys..., Base.front(args)...) @@ -1136,8 +1163,12 @@ end (x::InputFunctionWrapper)(v) = x.user_func(x.key, v) (x::InputFunctionWrapper)(inputs, changed, cached) = (x.user_func(x.key, inputs[1]),) -function add_input!(conversion_func, attr::ComputeGraph, args...) - return add_input!(conversion_func, attr, Base.front(args), last(args)) +function add_input!(conversion_func, attr::ComputeGraph, args...; force_update = false) + add_input!(conversion_func, attr, Base.front(args), last(args)) + if force_update + enable_forced_updates!(getindex(attr, Base.front(args)...).parent) + end + return attr end function add_input!(conversion_func, attr::ComputeGraphView, args...) diff --git a/ComputePipeline/src/utils.jl b/ComputePipeline/src/utils.jl index bbe83ec83ba..4033f6af6ec 100644 --- a/ComputePipeline/src/utils.jl +++ b/ComputePipeline/src/utils.jl @@ -19,6 +19,8 @@ Wraps a value in ComputeGraph to mark its update strategy. Can be: - `:auto`: propagate update if `is_same(previous_data, new_data)` is false Unmarked data uses `:auto`. + +See also [`unwrap_explicit_update`](@ref) """ function ExplicitUpdate(data::T, rule::Symbol = :auto) where {T} return ExplicitUpdate{T}(data, rule) @@ -34,3 +36,14 @@ function is_same(old, new::ExplicitUpdate) return new.rule == :deny end end + +""" + unwrap_explicit_update(x) + +Returns the value contained in an ExplicitUpdate `x` if one is passed. Otherwise +return `x`. +""" +unwrap_explicit_update(x) = x +unwrap_explicit_update(x::ComputePipeline.ExplicitUpdate) = x.data + +export ExplicitUpdate, unwrap_explicit_update \ No newline at end of file diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index d2f2d4b5408..1a0cb7d2d53 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -540,9 +540,6 @@ make_limit_update_explit(x::Tuple{<:Any, Nothing}) = ComputePipeline.ExplicitUpd make_limit_update_explit(x::Tuple{Nothing, Nothing}) = ComputePipeline.ExplicitUpdate(x, :force) make_limit_update_explit(x::Tuple) = ComputePipeline.ExplicitUpdate(x, :auto) -unwrap_explicit_update(x) = x -unwrap_explicit_update(x::ComputePipeline.ExplicitUpdate) = x.data - function initialize_limit_computations!(ax) attr = ax.attributes From 92aa9adf76284cb8ea5a9c035198960001012b9e Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 17:29:24 +0100 Subject: [PATCH 44/52] test forced_update, ExplicitUpdate, set_type!() --- ComputePipeline/test/unit_tests.jl | 338 +++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) diff --git a/ComputePipeline/test/unit_tests.jl b/ComputePipeline/test/unit_tests.jl index 14aaf1adb3f..222e86293b2 100644 --- a/ComputePipeline/test/unit_tests.jl +++ b/ComputePipeline/test/unit_tests.jl @@ -1314,3 +1314,341 @@ using ComputePipeline: ComputeGraphView @test_throws ErrorException add_input!(graph, :a, :z, 1) end end + +@testset "ExplicitUpdate and force_update" begin + + @testset "forced Input update propagation" begin + graph = ComputeGraph() + + add_input!(graph, :normal, 1) + add_input!(graph, :forced, 1, force_update = true) + add_input!((k, v) -> v+1, graph, :normalf, 1) + add_input!((k, v) -> v+1, graph, :forcedf, 1, force_update = true) + + @test graph.normal.parent.force_update == false + @test graph.forced.parent.force_update == true + @test graph.normalf.parent.force_update == false + @test graph.forcedf.parent.force_update == true + + function record_metrics(args, changed, cached) + metrics = isnothing(cached) ? Tuple{Bool, Int}[] : cached[1] + push!(metrics, (changed[1], args[1])) + return (metrics, ) + end + + register_computation!(record_metrics, graph, [:normal], [:normal_metrics]) + register_computation!(record_metrics, graph, [:forced], [:forced_metrics]) + register_computation!(record_metrics, graph, [:normalf], [:normalf_metrics]) + register_computation!(record_metrics, graph, [:forcedf], [:forcedf_metrics]) + + @test graph.normal_metrics[] == [(true, 1)] + @test graph.forced_metrics[] == [(true, 1)] + @test graph.normalf_metrics[] == [(true, 2)] + @test graph.forcedf_metrics[] == [(true, 2)] + + update!(graph, :normal => 1, :forced => 1, :normalf => 1, :forcedf => 1) + + @test graph.normal_metrics[] == [(true, 1)] + @test graph.forced_metrics[] == [(true, 1), (true, 1)] + @test graph.normalf_metrics[] == [(true, 2)] + @test graph.forcedf_metrics[] == [(true, 2), (true, 2)] + + update!(graph, :normal => 2, :forced => 2, :normalf => 2, :forcedf => 2) + + @test graph.normal_metrics[] == [(true, 1), (true, 2)] + @test graph.forced_metrics[] == [(true, 1), (true, 1), (true, 2)] + @test graph.normalf_metrics[] == [(true, 2), (true, 3)] + @test graph.forcedf_metrics[] == [(true, 2), (true, 2), (true, 3)] + end + + @testset "ExplicitUpdate" begin + graph = ComputeGraph() + add_input!(graph, :normal, 1) + add_input!(graph, :forced, 1, force_update = true) + ComputePipeline.set_type!(graph.normal, Any) + ComputePipeline.set_type!(graph.forced, Any) + + evaled = Symbol[] + + # passhtrough + map!(x -> begin push!(evaled, :normal2); x end, graph, :normal, :normal2) + map!(x -> begin push!(evaled, :forced2); x end, graph, :forced, :forced2) + ComputePipeline.set_type!(graph.normal2, Any) + ComputePipeline.set_type!(graph.forced2, Any) + + # Check that set_type!() works + @test isdefined(graph.normal, :value) && (graph.normal.value isa Base.RefValue{Any}) + @test isdefined(graph.normal2, :value) && (graph.normal2.value isa Base.RefValue{Any}) + @test isdefined(graph.forced, :value) && (graph.forced.value isa Base.RefValue{Any}) + @test isdefined(graph.forced2, :value) && (graph.forced2.value isa Base.RefValue{Any}) + + # set in computation + for source in (:normal, :forced) + for mode in (:deny, :auto, :force) + next = Symbol("$(mode)_$(source)") + final = Symbol("$(mode)_$(source)2") + map!(graph, source, next) do x + push!(evaled, next) + return ExplicitUpdate(unwrap_explicit_update(x), mode) + end + map!(graph, next, final) do x + push!(evaled, final) + return x + end + end + end + + # These should always trigger. + # input.forced -> output.forced always marks output.forced dirty + # output.forced -> (name in always) always runs but may not propagate further + always = [:forced2, :deny_forced, :auto_forced, :force_forced] + + # Normal value, not repeated + @test isempty(evaled) + @test graph.normal[] == 1 + @test graph.normal2[] == 1 + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(1, :auto) + @test graph.force_normal[] == ExplicitUpdate(1, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(1, :auto) + @test graph.force_normal2[] == ExplicitUpdate(1, :force) + @test graph.forced[] == 1 + @test graph.forced2[] == 1 + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(1, :auto) + @test graph.force_forced[] == ExplicitUpdate(1, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(1, :auto) + @test graph.force_forced2[] == ExplicitUpdate(1, :force) + @test evaled == [ + :normal2, + :deny_normal, :auto_normal, :force_normal, + :deny_normal2, :auto_normal2, :force_normal2, + always..., + :deny_forced2, :auto_forced2, :force_forced2, + ] + empty!(evaled) + + # Check that set_type!() did not get reset/overwritten + @test graph.normal.value isa Base.RefValue{Any} + @test graph.normal2.value isa Base.RefValue{Any} + @test graph.forced.value isa Base.RefValue{Any} + @test graph.forced2.value isa Base.RefValue{Any} + + @testset "equal value updates" begin + # Normal value, repeated + update!(graph, :normal => 1, :forced => 1) + @test isempty(evaled) + @test graph.normal[] == 1 + @test graph.normal2[] == 1 + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(1, :auto) + @test graph.force_normal[] == ExplicitUpdate(1, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(1, :auto) + @test graph.force_normal2[] == ExplicitUpdate(1, :force) + @test graph.forced[] == 1 + @test graph.forced2[] == 1 + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(1, :auto) + @test graph.force_forced[] == ExplicitUpdate(1, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(1, :auto) + @test graph.force_forced2[] == ExplicitUpdate(1, :force) + @test evaled == [always..., :force_forced2] + empty!(evaled) + + # forced update + # should get through Input, triggering first layer of nodes + # second layer depends on settings of those updates + update!(graph, :normal => ExplicitUpdate(1, :force), :forced => ExplicitUpdate(1, :force)) + @test isempty(evaled) + @test graph.normal[] == ExplicitUpdate(1, :force) + @test graph.normal2[] == ExplicitUpdate(1, :force) + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(1, :auto) + @test graph.force_normal[] == ExplicitUpdate(1, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(1, :auto) + @test graph.force_normal2[] == ExplicitUpdate(1, :force) + @test graph.forced[] == ExplicitUpdate(1, :force) + @test graph.forced2[] == ExplicitUpdate(1, :force) + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(1, :auto) + @test graph.force_forced[] == ExplicitUpdate(1, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(1, :auto) + @test graph.force_forced2[] == ExplicitUpdate(1, :force) + @test evaled == [ + :normal2, + :deny_normal, :auto_normal, :force_normal, + :force_normal2, + always..., + :force_forced2, + ] + empty!(evaled) + + # auto update - should behave like same value update without ExplicitUpdate + update!(graph, :normal => ExplicitUpdate(1, :auto), :forced => ExplicitUpdate(1, :auto)) + @test isempty(evaled) + @test graph.normal[] == ExplicitUpdate(1, :force) # same inner value, no update + @test graph.normal2[] == ExplicitUpdate(1, :force) + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(1, :auto) + @test graph.force_normal[] == ExplicitUpdate(1, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(1, :auto) + @test graph.force_normal2[] == ExplicitUpdate(1, :force) + @test graph.forced[] == ExplicitUpdate(1, :auto) # update forced by Input + @test graph.forced2[] == ExplicitUpdate(1, :force) # same value with :auto, no update + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(1, :auto) + @test graph.force_forced[] == ExplicitUpdate(1, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(1, :auto) + @test graph.force_forced2[] == ExplicitUpdate(1, :force) + @test evaled == [ + always..., + :force_forced2, + ] + empty!(evaled) + + # deny update - also same as equal value update as those deny + update!(graph, :normal => ExplicitUpdate(1, :deny), :forced => ExplicitUpdate(1, :deny)) + @test isempty(evaled) + @test graph.normal[] == ExplicitUpdate(1, :force) # update denied + @test graph.normal2[] == ExplicitUpdate(1, :force) + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(1, :auto) + @test graph.force_normal[] == ExplicitUpdate(1, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(1, :auto) + @test graph.force_normal2[] == ExplicitUpdate(1, :force) + @test graph.forced[] == ExplicitUpdate(1, :deny) # update forced by Input + @test graph.forced2[] == ExplicitUpdate(1, :force) # update denied + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(1, :auto) + @test graph.force_forced[] == ExplicitUpdate(1, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(1, :auto) + @test graph.force_forced2[] == ExplicitUpdate(1, :force) + @test evaled == [ + always..., + :force_forced2, + ] + empty!(evaled) + end + + @testset "Changed value updates" begin + # Normal value, changed + update!(graph, :normal => 2, :forced => 2) + @test isempty(evaled) + @test graph.normal[] == 2 + @test graph.normal2[] == 2 + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) # update denied by wrapping + @test graph.auto_normal[] == ExplicitUpdate(2, :auto) + @test graph.force_normal[] == ExplicitUpdate(2, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(2, :auto) + @test graph.force_normal2[] == ExplicitUpdate(2, :force) + @test graph.forced[] == 2 + @test graph.forced2[] == 2 + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) # update denied by wrapping + @test graph.auto_forced[] == ExplicitUpdate(2, :auto) + @test graph.force_forced[] == ExplicitUpdate(2, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(2, :auto) + @test graph.force_forced2[] == ExplicitUpdate(2, :force) + @test evaled == [ + :normal2, + :deny_normal, :auto_normal, :force_normal, + :auto_normal2, :force_normal2, + always..., + :auto_forced2, :force_forced2, + ] + empty!(evaled) + + # forced update, same as above as different values update + update!(graph, :normal => ExplicitUpdate(3, :force), :forced => ExplicitUpdate(3, :force)) + @test isempty(evaled) + @test graph.normal[] == ExplicitUpdate(3, :force) + @test graph.normal2[] == ExplicitUpdate(3, :force) + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(3, :auto) + @test graph.force_normal[] == ExplicitUpdate(3, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(3, :auto) + @test graph.force_normal2[] == ExplicitUpdate(3, :force) + @test graph.forced[] == ExplicitUpdate(3, :force) + @test graph.forced2[] == ExplicitUpdate(3, :force) + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(3, :auto) + @test graph.force_forced[] == ExplicitUpdate(3, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(3, :auto) + @test graph.force_forced2[] == ExplicitUpdate(3, :force) + @test evaled == [ + :normal2, + :deny_normal, :auto_normal, :force_normal, + :auto_normal2, :force_normal2, + always..., + :auto_forced2, :force_forced2, + ] + empty!(evaled) + + # auto update - should behave like same value update without ExplicitUpdate + update!(graph, :normal => ExplicitUpdate(4, :auto), :forced => ExplicitUpdate(4, :auto)) + @test isempty(evaled) + @test graph.normal[] == ExplicitUpdate(4, :auto) + @test graph.normal2[] == ExplicitUpdate(4, :auto) + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(4, :auto) + @test graph.force_normal[] == ExplicitUpdate(4, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(4, :auto) + @test graph.force_normal2[] == ExplicitUpdate(4, :force) + @test graph.forced[] == ExplicitUpdate(4, :auto) + @test graph.forced2[] == ExplicitUpdate(4, :auto) + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(4, :auto) + @test graph.force_forced[] == ExplicitUpdate(4, :force) + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(4, :auto) + @test graph.force_forced2[] == ExplicitUpdate(4, :force) + @test evaled == [ + :normal2, + :deny_normal, :auto_normal, :force_normal, + :auto_normal2, :force_normal2, + always..., + :auto_forced2, :force_forced2, + ] + empty!(evaled) + + # deny update - also same as equal value update as those deny + update!(graph, :normal => ExplicitUpdate(5, :deny), :forced => ExplicitUpdate(5, :deny)) + @test isempty(evaled) + @test graph.normal[] == ExplicitUpdate(4, :auto) # update denied + @test graph.normal2[] == ExplicitUpdate(4, :auto) + @test graph.deny_normal[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal[] == ExplicitUpdate(4, :auto) + @test graph.force_normal[] == ExplicitUpdate(4, :force) + @test graph.deny_normal2[] == ExplicitUpdate(1, :deny) + @test graph.auto_normal2[] == ExplicitUpdate(4, :auto) + @test graph.force_normal2[] == ExplicitUpdate(4, :force) + @test graph.forced[] == ExplicitUpdate(5, :deny) # update forced by Input + @test graph.forced2[] == ExplicitUpdate(4, :auto) # update denied + @test graph.deny_forced[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced[] == ExplicitUpdate(5, :auto) # Input forces these to run so that + @test graph.force_forced[] == ExplicitUpdate(5, :force) # :deny doesn't act before it gets replace + @test graph.deny_forced2[] == ExplicitUpdate(1, :deny) + @test graph.auto_forced2[] == ExplicitUpdate(5, :auto) + @test graph.force_forced2[] == ExplicitUpdate(5, :force) + @test evaled == [ + always..., + :auto_forced2, :force_forced2, + ] + empty!(evaled) + end + end +end \ No newline at end of file From 195832c7f3fcd9b92e17938b5881b4120267ae78 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 18:15:43 +0100 Subject: [PATCH 45/52] improve and test recursively connected Observable handling --- ComputePipeline/src/ComputePipeline.jl | 38 ++++++++++++++++++-------- ComputePipeline/test/unit_tests.jl | 36 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index c08226ca03e..b378a30009f 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -405,6 +405,7 @@ struct ComputeGraph <: AbstractComputeGraph nesting::NestedSearchTree onchange::Observable{Set{Symbol}} + obs_to_notify::Set{Symbol} observables::Dict{Symbol, Observable} should_deepcopy::Set{Symbol} observerfunctions::Vector{Observables.ObserverFunction} @@ -488,18 +489,19 @@ function ComputeGraph() graph = ComputeGraph( Dict{Symbol, ComputeEdge}(), Dict{Symbol, Computed}(), Base.ReentrantLock(), NestedSearchTree(), - Observable(Set{Symbol}()), Dict{Symbol, Observable}(), Set{Symbol}(), + Observable(Set{Symbol}()), Set{Symbol}(), + Dict{Symbol, Observable}(), Set{Symbol}(), Observables.ObserverFunction[], Observable[] ) - on(graph.onchange) do _changeset - # notifying observables may cause further updates to onchange which may - # corrupt state before we finish here. So copy changeset here and - # immediately prepare onchange for the next call - changeset = intersect(_changeset, keys(graph.observables)) - empty!(_changeset) + on(graph.onchange) do changeset + # Remove node names not backed by observables + intersect!(changeset, keys(graph.observables)) - # update data + obs_to_notify = graph.obs_to_notify + + # update values without triggering observables and add all updated names + # to obs_to_notify for key in changeset val = graph.outputs[key][] obs = graph.observables[key] @@ -507,19 +509,30 @@ function ComputeGraph() # anything updated in-place if !(key in graph.should_deepcopy) obs.val = val + push!(obs_to_notify, key) elseif val != obs[] # treat in-place updates obs.val = deepcopy(val) + push!(obs_to_notify, key) else # same value (with deepcopy), skip update - delete!(changeset, key) + # delete!(changeset, key) end end - # trigger observables - for key in changeset + # Clear the changeset now so that if notify causes the graph to update + # again, the already processed names are not processed again. + empty!(changeset) + + # trigger observables from obs_to_notify. + # Separating this from changeset allows notify to cause another + # on(onchange) to trigger without issues. + # - value setting & empty!(changeset) doesn't delete from obs_to_notify + # - the inner on(onchange) adds to the obs_to_notify Set, so no notifies + # don't duplicate + while !isempty(obs_to_notify) + key = pop!(obs_to_notify) notify(graph.observables[key]) end - # clear changeset after processing observables return Consume(false) end @@ -756,6 +769,7 @@ function Base.getproperty(attr::ComputeGraph, key::Symbol) key === :outputs && return getfield(attr, :outputs) key === :nesting && return getfield(attr, :nesting) key === :onchange && return getfield(attr, :onchange) + key === :obs_to_notify && return getfield(attr, :obs_to_notify) key === :observables && return getfield(attr, :observables) key === :observerfunctions && return getfield(attr, :observerfunctions) key === :obs_to_update && return getfield(attr, :obs_to_update) diff --git a/ComputePipeline/test/unit_tests.jl b/ComputePipeline/test/unit_tests.jl index 222e86293b2..a53a2f33278 100644 --- a/ComputePipeline/test/unit_tests.jl +++ b/ComputePipeline/test/unit_tests.jl @@ -583,6 +583,42 @@ end @test g.observables[:out2][] == Float32[6, 7, 2, 3, 1] @test g.observables[:zipped][] == tuple.(Float32[2, 3, 6, 7, 1], Float32[6, 7, 2, 3, 1]) end + + @testset "Recursive Loop" begin + graph = ComputeGraph() + add_input!(graph, :input, 0) + map!(x -> (x, x), graph, :input, [:out1, :out2]) + + triggered = Symbol[] + on(graph.out1) do x + push!(triggered, :obs1) + if isodd(x) + graph.input = x + 1 + end + return + end + on(graph.out2) do x + push!(triggered, :obs2) + if isodd(x) + graph.input = x + 1 + end + return + end + + @test graph.input[] == 0 + @test isempty(triggered) + + graph.input = 1 + # one obs should trigger the 1 + 1 + # this causes out1, out2 to update and be marked as changed + # this triggers on(onchange) again (from inside on(onchange)) + # this should update obs1.val and obs2.val and then mark both as outdated + # and then trigger all that are marked outdated, which should not contain copies + # so it should trigger both here, with neither updating the graph + # we should then step back to the original on(onchange) + # which should not have anything left to process as the inner on(onchange) processed everything + @test triggered == [:obs1, :obs1, :obs2] || triggered == [:obs2, :obs2, :ob1] + end end @testset "map and on" begin From ab77d6856892225a0cc565f694092d5b49c07d2a Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 18:19:14 +0100 Subject: [PATCH 46/52] format --- ComputePipeline/src/utils.jl | 2 +- ComputePipeline/test/unit_tests.jl | 18 ++++++++++++------ Makie/src/makielayout/blocks/axis3d.jl | 3 +-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/ComputePipeline/src/utils.jl b/ComputePipeline/src/utils.jl index 4033f6af6ec..e0dba3b39a1 100644 --- a/ComputePipeline/src/utils.jl +++ b/ComputePipeline/src/utils.jl @@ -46,4 +46,4 @@ return `x`. unwrap_explicit_update(x) = x unwrap_explicit_update(x::ComputePipeline.ExplicitUpdate) = x.data -export ExplicitUpdate, unwrap_explicit_update \ No newline at end of file +export ExplicitUpdate, unwrap_explicit_update diff --git a/ComputePipeline/test/unit_tests.jl b/ComputePipeline/test/unit_tests.jl index a53a2f33278..b878c2067c0 100644 --- a/ComputePipeline/test/unit_tests.jl +++ b/ComputePipeline/test/unit_tests.jl @@ -1358,8 +1358,8 @@ end add_input!(graph, :normal, 1) add_input!(graph, :forced, 1, force_update = true) - add_input!((k, v) -> v+1, graph, :normalf, 1) - add_input!((k, v) -> v+1, graph, :forcedf, 1, force_update = true) + add_input!((k, v) -> v + 1, graph, :normalf, 1) + add_input!((k, v) -> v + 1, graph, :forcedf, 1, force_update = true) @test graph.normal.parent.force_update == false @test graph.forced.parent.force_update == true @@ -1369,7 +1369,7 @@ end function record_metrics(args, changed, cached) metrics = isnothing(cached) ? Tuple{Bool, Int}[] : cached[1] push!(metrics, (changed[1], args[1])) - return (metrics, ) + return (metrics,) end register_computation!(record_metrics, graph, [:normal], [:normal_metrics]) @@ -1407,8 +1407,14 @@ end evaled = Symbol[] # passhtrough - map!(x -> begin push!(evaled, :normal2); x end, graph, :normal, :normal2) - map!(x -> begin push!(evaled, :forced2); x end, graph, :forced, :forced2) + map!(graph, :normal, :normal2) do x + push!(evaled, :normal2) + return x + end + map!(graph, :forced, :forced2) do x + push!(evaled, :forced2) + return x + end ComputePipeline.set_type!(graph.normal2, Any) ComputePipeline.set_type!(graph.forced2, Any) @@ -1687,4 +1693,4 @@ end empty!(evaled) end end -end \ No newline at end of file +end diff --git a/Makie/src/makielayout/blocks/axis3d.jl b/Makie/src/makielayout/blocks/axis3d.jl index e958a45de7c..2d623b0b290 100644 --- a/Makie/src/makielayout/blocks/axis3d.jl +++ b/Makie/src/makielayout/blocks/axis3d.jl @@ -1104,8 +1104,7 @@ function reset_limits!(ax::Axis3; xauto = true, yauto = true, zauto = true) error("Invalid z-limits as zlims[1] <= zlims[2] is not met for $zlims.") end - tlims = - Rect3f( + tlims = Rect3f( Vec3f(xlims[1], ylims[1], zlims[1]), Vec3f(xlims[2] - xlims[1], ylims[2] - ylims[1], zlims[2] - zlims[1]), ) From dd8ad51519a1373b46b469205d84eaac9a780b43 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 21:43:14 +0100 Subject: [PATCH 47/52] restore xlims!()/ylims!()/limits!() setting linked axis limits --- Makie/src/makielayout/blocks/axis.jl | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 1a0cb7d2d53..d38f9e8074d 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -799,6 +799,16 @@ function xlims!(ax::Axis, xlims) xreversed = reversed ) + for link in ax.xaxislinks + link === ax && continue + update!( + link.attributes, + limits = (xlims, link.limits[][2]), + _limit_update_rule = (:force, :deny), + xreversed = reversed + ) + end + return nothing end @@ -822,6 +832,16 @@ function Makie.ylims!(ax::Axis, ylims) yreversed = reversed ) + for link in ax.yaxislinks + link === ax && continue + update!( + link.attributes, + limits = (link.limits[][1], ylims), + _limit_update_rule = (:deny, :force), + yreversed = reversed + ) + end + return nothing end From a4df73d960da4bbf098d6876988b352afa525bab Mon Sep 17 00:00:00 2001 From: ffreyer Date: Sun, 8 Mar 2026 21:55:45 +0100 Subject: [PATCH 48/52] don't trigger two updates when calling limits!() --- Makie/src/makielayout/blocks/axis.jl | 103 ++++++++++++++++----------- 1 file changed, 60 insertions(+), 43 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index d38f9e8074d..3dd8949b214 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -779,70 +779,90 @@ function adjustlimits(limits, autolimitaspect, viewport, xautolimitmargin, yauto return BBox(xlims[1], xlims[2], ylims[1], ylims[2]) end -function xlims!(ax::Axis, xlims) - xlims = map(x -> convert_dim_value(ax, 1, x), xlims) +function prepare_user_lims(ax, _lims, idx) + dim = ("x", "y")[idx] + lims = map(x -> convert_dim_value(ax, idx, x), _lims) reversed = false - if length(xlims) != 2 - error("Invalid xlims length of $(length(xlims)), must be 2.") - elseif xlims[1] == xlims[2] && xlims[1] !== nothing - error("Can't set x limits to the same value $(xlims[1]).") - elseif all(x -> x isa Real, xlims) && xlims[1] > xlims[2] - xlims = reverse(xlims) + if length(lims) != 2 + error("Invalid $(dim)lims length of $(length(lims)), must be 2.") + elseif lims[1] == lims[2] && lims[1] !== nothing + error("Can't set $dim limits to the same value $(lims[1]).") + elseif all(x -> x isa Real, lims) && lims[1] > lims[2] + lims = reverse(lims) reversed = true end + return lims, reversed +end +function update_xlims_locally!(ax, xlims, xreversed) # update xlims if they changed, keep ylims update!( ax.attributes, limits = (xlims, ax.limits[][2]), _limit_update_rule = (:force, :deny), - xreversed = reversed + xreversed = xreversed + ) + return +end + +function update_ylims_locally!(ax, ylims, yreversed) + # update ylims if they changed, keep xlims + update!( + ax.attributes, + limits = (ax.limits[][1], ylims), + _limit_update_rule = (:deny, :force), + yreversed = yreversed ) + return +end + +function xlims!(ax::Axis, _xlims) + xlims, reversed = prepare_user_lims(ax, _xlims, 1) + update_xlims_locally!(ax, xlims, reversed) for link in ax.xaxislinks link === ax && continue - update!( - link.attributes, - limits = (xlims, link.limits[][2]), - _limit_update_rule = (:force, :deny), - xreversed = reversed - ) + update_xlims_locally!(link, xlims, reversed) end - return nothing + return end -function Makie.ylims!(ax::Axis, ylims) - ylims = map(x -> convert_dim_value(ax, 2, x), ylims) - reversed = false - if length(ylims) != 2 - error("Invalid ylims length of $(length(ylims)), must be 2.") - elseif ylims[1] == ylims[2] && ylims[1] !== nothing - error("Can't set y limits to the same value $(ylims[1]).") - elseif all(x -> x isa Real, ylims) && ylims[1] > ylims[2] - ylims = reverse(ylims) - reversed = true +function Makie.ylims!(ax::Axis, _ylims) + ylims, reversed = prepare_user_lims(ax, _ylims, 1) + update_ylims_locally!(ax, ylims, reversed) + + for link in ax.yaxislinks + link === ax && continue + update_ylims_locally!(link, ylims, reversed) end - # update ylims if they changed, keep xlims + return +end + +function _limits!(ax, lims) + xlims, xreversed = prepare_user_lims(ax, lims[1], 1) + ylims, yreversed = prepare_user_lims(ax, lims[2], 2) + + # update xlims if they changed, keep ylims update!( ax.attributes, - limits = (ax.limits[][1], ylims), - _limit_update_rule = (:deny, :force), - yreversed = reversed + limits = (xlims, ylims), + xreversed = xreversed, + yreversed = yreversed ) + for link in ax.xaxislinks + link === ax && continue + update_xlims_locally!(link, xlims, xreversed) + end + for link in ax.yaxislinks link === ax && continue - update!( - link.attributes, - limits = (link.limits[][1], ylims), - _limit_update_rule = (:deny, :force), - yreversed = reversed - ) + update_ylims_locally!(link, ylims, yreversed) end - return nothing + return end function autolimits!(ax::Axis) @@ -1352,8 +1372,7 @@ Set the axis limits to `xlims` and `ylims`. If limits are ordered high-low, this reverses the axis orientation. """ function limits!(ax::Axis, xlims, ylims) - Makie.xlims!(ax, xlims) - return Makie.ylims!(ax, ylims) + return _limits!(ax, (xlims, ylims)) end """ @@ -1363,8 +1382,7 @@ Set the axis x-limits to `x1` and `x2` and the y-limits to `y1` and `y2`. If limits are ordered high-low, this reverses the axis orientation. """ function limits!(ax::Axis, x1, x2, y1, y2) - Makie.xlims!(ax, x1, x2) - return Makie.ylims!(ax, y1, y2) + return _limits!(ax, ((x1, x2), (y1, y2))) end """ @@ -1376,8 +1394,7 @@ If limits are ordered high-low, this reverses the axis orientation. function limits!(ax::Axis, rect::Rect2) xmin, ymin = minimum(rect) xmax, ymax = maximum(rect) - Makie.xlims!(ax, xmin, xmax) - return Makie.ylims!(ax, ymin, ymax) + return _limits!(ax, ((xmin, xmax), (ymin, ymax))) end function limits!(args::Union{Nothing, Real, HyperRectangle}...) From 922810d611d4e6e66000ba01278df1a20520c8ad Mon Sep 17 00:00:00 2001 From: ffreyer Date: Mon, 9 Mar 2026 00:12:19 +0100 Subject: [PATCH 49/52] fix limit errors --- Makie/src/makielayout/blocks/axis.jl | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index 3dd8949b214..b8302587b57 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -781,7 +781,7 @@ end function prepare_user_lims(ax, _lims, idx) dim = ("x", "y")[idx] - lims = map(x -> convert_dim_value(ax, idx, x), _lims) + lims = map(x -> convert_dim_value(ax, idx, x), _convert_single_limit(_lims)) reversed = false if length(lims) != 2 error("Invalid $(dim)lims length of $(length(lims)), must be 2.") @@ -829,7 +829,7 @@ function xlims!(ax::Axis, _xlims) end function Makie.ylims!(ax::Axis, _ylims) - ylims, reversed = prepare_user_lims(ax, _ylims, 1) + ylims, reversed = prepare_user_lims(ax, _ylims, 2) update_ylims_locally!(ax, ylims, reversed) for link in ax.yaxislinks @@ -938,14 +938,15 @@ function convert_limit_attribute(lims::Tuple{Any, Any, Any, Any}) return (lims[1:2], lims[3:4]) end +_convert_single_limit(x::Nothing) = x +_convert_single_limit(x::Interval) = endpoints(x) +_convert_single_limit(x::VecTypes{2}) = (x[1], x[2]) +function _convert_single_limit(x::AbstractArray) + length(x) == 2 || error("Each dimension of limits must have 2 values, the minimum and maximum.") + return (x[1], x[2]) +end + function convert_limit_attribute(lims::Tuple{Any, Any}) - _convert_single_limit(x::Nothing) = x - _convert_single_limit(x::Interval) = endpoints(x) - _convert_single_limit(x::VecTypes{2}) = (x[1], x[2]) - function _convert_single_limit(x::AbstractArray) - length(x) == 2 || error("Each dimension of limits must have 2 values, the minimum and maximum.") - return (x[1], x[2]) - end return map(_convert_single_limit, lims) end From 8d67326dd9b9f912288f21eecb626854887d79ae Mon Sep 17 00:00:00 2001 From: ffreyer Date: Mon, 9 Mar 2026 16:01:06 +0100 Subject: [PATCH 50/52] more compute docs --- ComputePipeline/src/ComputePipeline.jl | 10 ++ Makie/src/makielayout/blocks/axis.jl | 2 +- docs/src/explanations/compute-pipeline.md | 111 +++++++++++++++++++++- 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index b378a30009f..c19b6d428e2 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -2100,6 +2100,16 @@ function TypedEdge_no_call(edge::ComputeEdge) return TypedEdge(edge.callback, inputs, edge.inputs_dirty, outputs, edge.outputs) end +""" + set_type!(node::Computed, type) + +Initialize a compute graph `node` to the given `type`. + +``` +map!(x -> rand([1, 1.0, "1"]), graph, :input, :output) +set_type!(graph.output, Union{Int, Float64, String}) +``` +""" function set_type!(node::Computed, T::Type) if isdefined(node, :value) error("Node already initialized.") diff --git a/Makie/src/makielayout/blocks/axis.jl b/Makie/src/makielayout/blocks/axis.jl index b8302587b57..d040a6ac33d 100644 --- a/Makie/src/makielayout/blocks/axis.jl +++ b/Makie/src/makielayout/blocks/axis.jl @@ -828,7 +828,7 @@ function xlims!(ax::Axis, _xlims) return end -function Makie.ylims!(ax::Axis, _ylims) +function ylims!(ax::Axis, _ylims) ylims, reversed = prepare_user_lims(ax, _ylims, 2) update_ylims_locally!(ax, ylims, reversed) diff --git a/docs/src/explanations/compute-pipeline.md b/docs/src/explanations/compute-pipeline.md index cab6bacb627..6c10c7b5eb7 100644 --- a/docs/src/explanations/compute-pipeline.md +++ b/docs/src/explanations/compute-pipeline.md @@ -159,4 +159,113 @@ add_input!(graph.nested, :input3, 3) add_constant(graph.nested, :constant, 0) # graph.nested.input3 -> graph.nested.output1 map!(x -> 2x, graph.nested, :input3, :output1) -``` \ No newline at end of file +``` + +## Explicit Initialization + +The content of a compute node can be initialized explicitly with `ComputePipeline.unsafe_init!(node, value)`. +Alternatively, you can check the type of the `cached` value given in `register_computation!()` to detect the initializing call: + +```julia +register_computation!(graph, [:input], [:output]) do (input,), changed, cached + if isnothing(cached) # initialization + return (Float64[input],) + else # post initialization + buffer = cached.input # or cached[1] + push!(buffer, input) + return (buffer,) + end +end +``` + +Similarly, the type of a compute node can be initialized explicitly with `ComputePipeline.set_type!(node, type)` or at runtime by returning a `Ref{type}(value)`: + +```julia +map!(graph, :input, :output) do input + value = rand([1, 1.0, "1"]) + return Ref{Union{Int, Float64, String}}(value) +end +``` + +## Controlling Update Propagation + +The compute graph will try to avoid propagating updates to the same value. +To do this, it calls `ComputePipeline.is_same(old, new)` for every output of a computation and only propagates updates where this function returns `false`. +The default implementation looks like this: + +```julia +is_same(@nospecialize(old), @nospecialize(new)) = false +function is_same(old::T, new::T) where {T} + if isbitstype(T) + return old === new + else + # object might be mutated which can't be detected if old === new + same_object = old === new + return same_object ? false : isequal(old, new) + end +end +``` + +If you want your own type to behave differently from the default implementation, you can add a method to `is_same`. + +If you want to control whether a specific value propagates, you can wrap it in `ExplicitUpdate(value, update_rule)`. +The `update_rule` can be set to `:force` to force propagation (`is_same` return false), `:deny` to deny propagation (`is_same` return true) or `:auto` to use the default behavior, comparing the value in the wrapper to the previous value. +Note that `ExplicitUpdate` will not be removed automatically to allow callbacks to see the update rule of an input. +You can use `unwrap_explicit_update` to remove it. + +For input nodes there is also option to always force values to propagate by setting `force_update = true` in `add_input!()`, or by calling `ComputePipeline.enable_forced_updates!(input_node)`. +This can be useful if you want to hide `ExplicitUpdate` from the input/user layer of the graph. + +```julia +graph = ComputeGraph() +add_input!(graph, :input, 5, force_update = true) + +map!(graph, :input, [:force, :auto, :deny]) do input + return ( + ExplicitUpdate(input, :force), + ExplicitUpdate(input, :auto), + ExplicitUpdate(input, :deny), + ) +end + +register_computation!(graph, [:force], [:force_output]) do args, changed, cached + return (isnothing(cached) ? 1 : cached[1] + 1, ) +end +register_computation!(graph, [:auto], [:auto_output]) do args, changed, cached + return (isnothing(cached) ? 1 : cached[1] + 1, ) +end +register_computation!(graph, [:deny], [:deny_output]) do args, changed, cached + return (isnothing(cached) ? 1 : cached[1] + 1, ) +end + +# all will update once +graph.force[] # ExplicitUpdate(5, :force) +graph.auto[] # ExplicitUpdate(5, :auto) +graph.deny[] # ExplicitUpdate(5, :deny) +graph.force_output[] # 1 +graph.auto_output[] # 1 +graph.deny_output[] # 1 + +# When updating to the same value only the force node +# will trigger further updates +update!(graph, :input => 5) +graph.force[] # set to ExplicitUpdate(5, :force) +graph.auto[] # retained ExplicitUpdate(5, :auto) +graph.deny[] # retained ExplicitUpdate(5, :deny) +graph.force_output[] # 2 +graph.auto_output[] # 1 +graph.deny_output[] # 1 + +# For a different value auto -> auto_output will also update +# deny -> deny_output will never update +update!(graph, :input => 2) +graph.force[] # set to ExplicitUpdate(2, :force) +graph.auto[] # set to ExplicitUpdate(2, :auto) +graph.deny[] # retained ExplicitUpdate(5, :deny) +graph.force_output[] # 3 +graph.auto_output[] # 2 +graph.deny_output[] # 1 +``` + +Note that if you want a node to work with plain values and values wrapped in `ExplicitUpdate` you will need to initialize its type to a union. +For example `set_type!(node, Union{Int64, ExplicitUpdate{Int64}})`. \ No newline at end of file From 39ed48d5b8b079706f616853a7f6fc6acb7400d0 Mon Sep 17 00:00:00 2001 From: ffreyer Date: Mon, 9 Mar 2026 16:39:40 +0100 Subject: [PATCH 51/52] update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45abbf40f10..0ba7fcaa670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ - `initialize_block!(b::MyBlock)` is used to initialize the recipe with blocks and plots analogously to `plot!(p::MyPlot)`. The parent block `b::MyBlock` should be treated like a figure here, e.g. `Axis(b[1, 1])` - After defining the block, it can be added to a figure like any other block `mb = MyBlock(fig[1, 1])`. - The blocks within `MyBlock` can be accessed via the layout `mb.layout`, `mb.blocks` or `mb[i, j]`. +- Refactored `Axis` to use the compute graph [#5546](https://github.com/MakieOrg/Makie.jl/pull/5546) + - **minor breaking** Custom interactions that manipulated `ax.targetlimits` should now update `ax.localxlimits` and `ax.localylimits` instead and read from either `ax.targetlimits` or `sharedxlimits` and `sharedylimits`. Otherwise they will not correctly update linked axes. + - Redisplaying a figure after emptying an axis now resets its limits if they aren't set to specific values. +- Fixed an issue where Observable outputs of compute nodes that cycle back into the compute graph could discard updates of other Observable outputs. [#5546](https://github.com/MakieOrg/Makie.jl/pull/5546) +- Added `ComputePipeline.set_type!(node, type)` for initializing the type of a compute graph node [#5546](https://github.com/MakieOrg/Makie.jl/pull/5546) +- Added `ExplicitUpdate` wrapper to control update propagation for computations in the compute graph. Also added an option for forcefully propagate updates from input nodes. [#5546](https://github.com/MakieOrg/Makie.jl/pull/5546) ## Unreleased From b1ca21cf058e3eb526451a23ccce03d46e5660ab Mon Sep 17 00:00:00 2001 From: ffreyer Date: Mon, 9 Mar 2026 16:52:19 +0100 Subject: [PATCH 52/52] fix categorical update tests? --- ComputePipeline/src/ComputePipeline.jl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ComputePipeline/src/ComputePipeline.jl b/ComputePipeline/src/ComputePipeline.jl index c19b6d428e2..39fe0503477 100644 --- a/ComputePipeline/src/ComputePipeline.jl +++ b/ComputePipeline/src/ComputePipeline.jl @@ -503,6 +503,9 @@ function ComputeGraph() # update values without triggering observables and add all updated names # to obs_to_notify for key in changeset + # Still necessary? + haskey(graph.observables, key) || continue + val = graph.outputs[key][] obs = graph.observables[key] # Trust the graph to discard equal values. This doesn't work for