diff --git a/NEWS.md b/NEWS.md index 78fb7f03583..e039f6e4f21 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # News ## master +- Allow arbitrary reversible scale functions through `ReversibleScale`. - Fixed some errors around dynamic changes of `ax.xscale` or `ax.yscale` [#3084](https://github.com/MakieOrg/Makie.jl/pull/3084) - Improved Barplot Label Alignment [#3160](https://github.com/MakieOrg/Makie.jl/issues/3160). diff --git a/docs/reference/plots/contour.md b/docs/reference/plots/contour.md index c1aa8231645..4c9d8fd5bdd 100644 --- a/docs/reference/plots/contour.md +++ b/docs/reference/plots/contour.md @@ -57,8 +57,8 @@ x = y = range(-6, 6; length=100) z = himmelblau.(x, y') levels = 10.0.^range(0.3, 3.5; length=10) -colormap = Makie.sampler(:hsv, 100; scaling=Makie.Scaling(x -> x^(1 / 10), nothing)) -f, ax, ct = contour(x, y, z; labels=true, levels, colormap) +colorscale = ReversibleScale(x -> x^(1 / 10), x -> x^10) +f, ax, ct = contour(x, y, z; labels=true, levels, colormap=:hsv, colorscale) f ``` \end{examplefigure} diff --git a/docs/reference/plots/heatmap.md b/docs/reference/plots/heatmap.md index fb6e8325774..a7302c53b7b 100644 --- a/docs/reference/plots/heatmap.md +++ b/docs/reference/plots/heatmap.md @@ -116,3 +116,25 @@ Colorbar(fig[:, end+1], colorrange = joint_limits) # equivalent fig ``` \end{examplefigure} + + +### Using a custom colorscale + +One can define a custom (color)scale using the `ReversibleScale` type. When the transformation is simple enough (`log`, `sqrt`, ...), the inverse transform is automatically deduced. + +\begin{examplefigure}{} +```julia +using CairoMakie +CairoMakie.activate!() # hide + +x = 10.0.^(1:0.1:4) +y = 1.0:0.1:5.0 +z = broadcast((x, y) -> x - 10, x, y') + +scale = ReversibleScale(x -> asinh(x / 2) / log(10), x -> 2sinh(log(10) * x)) +fig, ax, hm = heatmap(x, y, z; colorscale = scale, axis = (; xscale = scale)) +Colorbar(fig[1, 2], hm) + +fig +``` +\end{examplefigure} diff --git a/src/Makie.jl b/src/Makie.jl index f72041008aa..b686e50cd48 100644 --- a/src/Makie.jl +++ b/src/Makie.jl @@ -287,6 +287,7 @@ export save, colorbuffer export cgrad, available_gradients, showgradients export Pattern +export ReversibleScale export assetpath # default icon for Makie diff --git a/src/colorsampler.jl b/src/colorsampler.jl index 71cc0d275a0..47a16029f98 100644 --- a/src/colorsampler.jl +++ b/src/colorsampler.jl @@ -179,7 +179,7 @@ end struct ColorMap{N,T<:AbstractArray{<:Number,N},T2<:AbstractArray{<:Number,N}} color::Observable{T} colormap::Observable{Vector{RGBAf}} - scale::Observable{Function} + scale::Observable{Union{ReversibleScale, Function}} mapping::Observable{Union{Nothing, Vector{Float64}}} colorrange::Observable{Vec{2,Float64}} @@ -198,7 +198,7 @@ function assemble_colors(::T, @nospecialize(color), @nospecialize(plot)) where { color_tight = convert(Observable{T}, color) colormap = Observable(RGBAf[]; ignore_equal_values=true) categorical = Observable(false) - colorscale = convert(Observable{Function}, plot.colorscale) + colorscale = convert(Observable{Union{ReversibleScale, Function}}, plot.colorscale) mapping = Observable{Union{Nothing, Vector{Float64}}}(nothing) function update_colors(cmap, a) diff --git a/src/layouting/transformation.jl b/src/layouting/transformation.jl index 96ed01fbace..897c49e5576 100644 --- a/src/layouting/transformation.jl +++ b/src/layouting/transformation.jl @@ -339,7 +339,6 @@ function apply_transform(f, itr::ClosedInterval) return apply_transform(f, mini) .. apply_transform(f, maxi) end - function apply_transform(f, r::Rect) mi = minimum(r) ma = maximum(r) @@ -360,66 +359,42 @@ apply_transform(f::typeof(identity), r::Rect) = r apply_transform(f::NTuple{2, typeof(identity)}, r::Rect) = r apply_transform(f::NTuple{3, typeof(identity)}, r::Rect) = r +const pseudolog10 = ReversibleScale( + x -> sign(x) * log10(abs(x) + 1), + x -> sign(x) * (exp10(abs(x)) - 1); + limits=(0f0, 3f0) +) -pseudolog10(x) = sign(x) * log10(abs(x) + 1) -inv_pseudolog10(x) = sign(x) * (exp10(abs(x)) - 1) - -struct Symlog10 - low::Float64 - high::Float64 - function Symlog10(low, high) - if !(low < 0 && high > 0) - error("Low bound needs to be smaller than 0 and high bound larger than 0. You gave $low, $high.") - end - new(Float64(low), Float64(high)) - end -end - -Symlog10(x) = Symlog10(-x, x) - -function (s::Symlog10)(x) - if x > 0 - x <= s.high ? x / s.high * log10(s.high) : log10(x) +Symlog10(hi) = Symlog10(-hi, hi) +function Symlog10(lo, hi) + forward(x) = if x > 0 + x <= hi ? x / hi * log10(hi) : log10(x) elseif x < 0 - x >= s.low ? x / abs(s.low) * log10(abs(s.low)) : sign(x) * log10(abs(x)) + x >= lo ? x / abs(lo) * log10(abs(lo)) : -log10(abs(x)) else x end -end - -function inv_symlog10(x, low, high) - if x > 0 - l = log10(high) - x <= l ? x / l * high : exp10(x) + inverse(x) = if x > 0 + l = log10(hi) + x <= l ? x / l * hi : exp10(x) elseif x < 0 - l = sign(x) * log10(abs(low)) - x >= l ? x / l * abs(low) : sign(x) * exp10(abs(x)) + l = -log10(abs(lo)) + x >= l ? x / l * abs(lo) : -exp10(abs(x)) else x end + ReversibleScale(forward, inverse; limits=(0f0, 3f0)) end -const REVERSIBLE_SCALES = Union{ - # typeof(identity), # no, this is a noop - typeof(log10), - typeof(log), - typeof(log2), - typeof(sqrt), - typeof(pseudolog10), - typeof(logit), - Symlog10, -} - inverse_transform(::typeof(identity)) = identity inverse_transform(::typeof(log10)) = exp10 -inverse_transform(::typeof(log)) = exp inverse_transform(::typeof(log2)) = exp2 +inverse_transform(::typeof(log)) = exp inverse_transform(::typeof(sqrt)) = x -> x ^ 2 -inverse_transform(::typeof(pseudolog10)) = inv_pseudolog10 inverse_transform(F::Tuple) = map(inverse_transform, F) inverse_transform(::typeof(logit)) = logistic -inverse_transform(s::Symlog10) = x -> inv_symlog10(x, s.low, s.high) -inverse_transform(s) = nothing +inverse_transform(s::ReversibleScale) = s.inverse +inverse_transform(::Any) = nothing function is_identity_transform(t) return t === identity || t isa Tuple && all(x-> x === identity, t) diff --git a/src/makielayout/blocks/axis.jl b/src/makielayout/blocks/axis.jl index ee6edd121a6..44ecafcf582 100644 --- a/src/makielayout/blocks/axis.jl +++ b/src/makielayout/blocks/axis.jl @@ -495,7 +495,7 @@ function initialize_block!(ax::Axis; palette = nothing) register_events!(ax, scene) # these are the user defined limits - on(blockscene, ax.limits) do mlims + on(blockscene, ax.limits) do _ reset_limits!(ax) end @@ -1352,22 +1352,19 @@ defaultlimits(limits::Tuple{Real, Nothing}, scale) = (limits[1], defaultlimits(s defaultlimits(limits::Tuple{Nothing, Real}, scale) = (defaultlimits(scale)[1], limits[2]) defaultlimits(limits::Tuple{Nothing, Nothing}, scale) = defaultlimits(scale) - -defaultlimits(::typeof(log10)) = (1.0, 1000.0) -defaultlimits(::typeof(log2)) = (1.0, 8.0) -defaultlimits(::typeof(log)) = (1.0, exp(3.0)) +defaultlimits(scale::ReversibleScale) = inverse_transform(scale).(scale.limits) +defaultlimits(scale::LogFunctions) = let inv_scale = inverse_transform(scale) + (inv_scale(0.0), inv_scale(3.0)) +end defaultlimits(::typeof(identity)) = (0.0, 10.0) defaultlimits(::typeof(sqrt)) = (0.0, 100.0) defaultlimits(::typeof(Makie.logit)) = (0.01, 0.99) -defaultlimits(::typeof(Makie.pseudolog10)) = (0.0, 100.0) -defaultlimits(::Makie.Symlog10) = (0.0, 100.0) +defined_interval(scale::ReversibleScale) = scale.interval defined_interval(::typeof(identity)) = OpenInterval(-Inf, Inf) -defined_interval(::Union{typeof(log2), typeof(log10), typeof(log)}) = OpenInterval(0.0, Inf) +defined_interval(::LogFunctions) = OpenInterval(0.0, Inf) defined_interval(::typeof(sqrt)) = Interval{:closed,:open}(0, Inf) defined_interval(::typeof(Makie.logit)) = OpenInterval(0.0, 1.0) -defined_interval(::typeof(Makie.pseudolog10)) = OpenInterval(-Inf, Inf) -defined_interval(::Makie.Symlog10) = OpenInterval(-Inf, Inf) function update_state_before_display!(ax::Axis) reset_limits!(ax) diff --git a/src/makielayout/blocks/colorbar.jl b/src/makielayout/blocks/colorbar.jl index b2e07f9ca89..266d6efb049 100644 --- a/src/makielayout/blocks/colorbar.jl +++ b/src/makielayout/blocks/colorbar.jl @@ -90,9 +90,15 @@ function Colorbar(fig_or_scene, voronoi::Voronoiplot; kwargs...) ) end -colorbar_range(start, stop, length, _) = LinRange(start, stop, length) # noop -function colorbar_range(start, stop, length, scale::REVERSIBLE_SCALES) - inverse_transform(scale).(range(start, stop; length)) +function colorbar_range(start, stop, length, colorscale) + colorscale === identity && return LinRange(start, stop, length) + + inverse = inverse_transform(colorscale) + isnothing(inverse) && throw(ArgumentError( + "Cannot determine inverse transform: you can use `ReversibleScale($(colorscale), inverse($(colorscale)))` instead." + )) + + inverse.(range(start, stop; length)) end function initialize_block!(cb::Colorbar) @@ -168,7 +174,7 @@ function initialize_block!(cb::Colorbar) map_is_categorical = lift(x -> x isa PlotUtils.CategoricalColorGradient, blockscene, cgradient) steps = lift(blockscene, cgradient, cb.nsteps, cb.scale) do cgradient, n, scale - s = if cgradient isa PlotUtils.CategoricalColorGradient + if cgradient isa PlotUtils.CategoricalColorGradient cgradient.values else collect(colorbar_range(0, 1, n, scale)) @@ -385,17 +391,13 @@ end Sets the space allocated for the ticklabels of the `Colorbar` to the minimum that is needed and returns that value. """ -function tight_ticklabel_spacing!(cb::Colorbar) - space = tight_ticklabel_spacing!(cb.axis) - return space -end +tight_ticklabel_spacing!(cb::Colorbar) = tight_ticklabel_spacing!(cb.axis) function scaled_steps(steps, scale, lims) - # first scale to limits so we can actually apply the scale to the values - # (log(0) doesn't work etc.) - s_limits = steps .* (lims[2] - lims[1]) .+ lims[1] # scale with scaling function - s_limits_scaled = scale.(s_limits) + steps_scaled = scale.(steps) + # normalize to lims range + steps_lim_scaled = @. steps_scaled * (scale(lims[2]) - scale(lims[1])) + scale(lims[1]) # then rescale to 0 to 1 - s_scaled = (s_limits_scaled .- s_limits_scaled[1]) ./ (s_limits_scaled[end] - s_limits_scaled[1]) + @. (steps_lim_scaled - steps_lim_scaled[begin]) / (steps_lim_scaled[end] - steps_lim_scaled[begin]) end diff --git a/src/makielayout/lineaxis.jl b/src/makielayout/lineaxis.jl index d8dbcd3b6a9..41ee1c9a100 100644 --- a/src/makielayout/lineaxis.jl +++ b/src/makielayout/lineaxis.jl @@ -556,12 +556,12 @@ end get_tickvalues(::Automatic, ::typeof(identity), vmin, vmax) = get_tickvalues(WilkinsonTicks(5, k_min = 3), vmin, vmax) # fall back to identity if not overloaded scale function is used with automatic -get_tickvalues(::Automatic, F, vmin, vmax) = get_tickvalues(automatic, identity, vmin, vmax) +get_tickvalues(::Automatic, _, vmin, vmax) = get_tickvalues(automatic, identity, vmin, vmax) # fall back to non-scale aware behavior if no special version is overloaded -get_tickvalues(ticks, scale, vmin, vmax) = get_tickvalues(ticks, vmin, vmax) +get_tickvalues(ticks, _, vmin, vmax) = get_tickvalues(ticks, vmin, vmax) -function get_ticks(ticks_and_labels::Tuple{Any, Any}, any_scale, ::Automatic, vmin, vmax) +function get_ticks(ticks_and_labels::Tuple{Any, Any}, _, ::Automatic, vmin, vmax) n1 = length(ticks_and_labels[1]) n2 = length(ticks_and_labels[2]) if n1 != n2 @@ -570,7 +570,7 @@ function get_ticks(ticks_and_labels::Tuple{Any, Any}, any_scale, ::Automatic, vm ticks_and_labels end -function get_ticks(tickfunction::Function, any_scale, formatter, vmin, vmax) +function get_ticks(tickfunction::Function, _, formatter, vmin, vmax) result = tickfunction(vmin, vmax) if result isa Tuple{Any, Any} tickvalues, ticklabels = result @@ -585,14 +585,13 @@ _logbase(::typeof(log10)) = "10" _logbase(::typeof(log2)) = "2" _logbase(::typeof(log)) = "e" - -function get_ticks(::Automatic, scale::Union{typeof(log10), typeof(log2), typeof(log)}, - any_formatter, vmin, vmax) - get_ticks(LogTicks(WilkinsonTicks(5, k_min = 3)), scale, any_formatter, vmin, vmax) +function get_ticks(::Automatic, scale::LogFunctions, any_formatter, vmin, vmax) + ticks = LogTicks(WilkinsonTicks(5, k_min = 3)) + get_ticks(ticks, scale, any_formatter, vmin, vmax) end # log ticks just use the normal pipeline but with log'd limits, then transform the labels -function get_ticks(l::LogTicks, scale::Union{typeof(log10), typeof(log2), typeof(log)}, ::Automatic, vmin, vmax) +function get_ticks(l::LogTicks, scale::LogFunctions, ::Automatic, vmin, vmax) ticks_scaled = get_tickvalues(l.linear_ticks, identity, scale(vmin), scale(vmax)) ticks = Makie.inverse_transform(scale).(ticks_scaled) @@ -605,7 +604,7 @@ function get_ticks(l::LogTicks, scale::Union{typeof(log10), typeof(log2), typeof ) labels = rich.(_logbase(scale), superscript.(labels_scaled, offset = Vec2f(0.1f0, 0f0))) - (ticks, labels) + ticks, labels end # function get_ticks(::Automatic, scale::typeof(Makie.logit), any_formatter, vmin, vmax) @@ -684,7 +683,6 @@ Gets tick labels by formatting each value in `values` according to a `Formatting """ get_ticklabels(formatstring::AbstractString, values) = [Formatting.format(formatstring, v) for v in values] - function get_ticks(m::MultiplesTicks, any_scale, ::Automatic, vmin, vmax) dvmin = vmin / m.multiple dvmax = vmax / m.multiple @@ -727,8 +725,7 @@ function get_minor_tickvalues(i::IntervalsBetween, scale, tickvalues, vmin, vmax end # for log scales, we need to step in log steps at the edges -function get_minor_tickvalues(i::IntervalsBetween, scale::Union{typeof(log),typeof(log2),typeof(log10)}, - tickvalues, vmin, vmax) +function get_minor_tickvalues(i::IntervalsBetween, scale::LogFunctions, tickvalues, vmin, vmax) vals = Float64[] length(tickvalues) < 2 && return vals n = i.n diff --git a/src/types.jl b/src/types.jl index c28e74df6a8..fd8fb4078c6 100644 --- a/src/types.jl +++ b/src/types.jl @@ -200,6 +200,9 @@ end Struct to hold all relevant matrices and additional parameters, to let backends apply camera based transformations. + +## Fields +$(TYPEDFIELDS) """ struct Camera """ @@ -378,3 +381,51 @@ end # The color type we ideally use for most color attributes const RGBColors = Union{RGBAf, Vector{RGBAf}, Vector{Float32}} + +const LogFunctions = Union{typeof(log10), typeof(log2), typeof(log)} + +""" + ReversibleScale + +Custom scale struct, taking a forward and inverse arbitrary scale function. + +## Fields +$(TYPEDFIELDS) +""" +struct ReversibleScale{F <: Function, I <: Function, T <: AbstractInterval} + """ + forward transformation (e.g. `log10`) + """ + forward::F + """ + inverse transformation (e.g. `exp10` for `log10` such that inverse ∘ forward ≡ identity) + """ + inverse::I + """ + default limits (optional) + """ + limits::NTuple{2,Float32} + """ + valid limits interval (optional) + """ + interval::T + function ReversibleScale(forward, inverse = Automatic(); limits = (0f0, 10f0), interval = (-Inf32, Inf32)) + inverse isa Automatic && (inverse = inverse_transform(forward)) + isnothing(inverse) && throw(ArgumentError( + "Cannot determine inverse transform: you can use `ReversibleScale($(forward), inverse($(forward)))` instead." + )) + interval isa AbstractInterval || (interval = OpenInterval(Float32.(interval)...)) + + lft, rgt = limits = Tuple(Float32.(limits)) + + Id = inverse ∘ forward + lft ≈ Id(lft) || throw(ArgumentError("Invalid inverse transform: $lft !≈ $(Id(lft))")) + rgt ≈ Id(rgt) || throw(ArgumentError("Invalid inverse transform: $rgt !≈ $(Id(rgt))")) + + new{typeof(forward),typeof(inverse),typeof(interval)}(forward, inverse, limits, interval) + end +end + +function (s::ReversibleScale)(args...) # functor + s.forward(args...) +end diff --git a/test/makielayout.jl b/test/makielayout.jl index ae4586b8d69..54ebaec3f7c 100644 --- a/test/makielayout.jl +++ b/test/makielayout.jl @@ -377,6 +377,31 @@ end f end +@testset "ReversibleScale" begin + @test ReversibleScale(identity).inverse === identity + @test ReversibleScale(log).inverse === exp + @test_throws ArgumentError ReversibleScale(x -> log10(x)) # missing inverse scale + @test_throws ArgumentError ReversibleScale(sqrt, exp10) # incorrect inverse scale +end + +@testset "Invalid inverse transform" begin + f = Figure() + @test_throws ArgumentError Colorbar(f[1, 1], limits = (1, 100), scale = x -> log10(x)) +end + +@testset "Colorscales" begin + x = 10.0.^(1:0.1:4) + y = 1.0:0.1:5.0 + z = broadcast((x, y) -> x, x, y') + + scale = Makie.Symlog10(2) + fig, ax, hm = heatmap(x, y, z; colorscale = scale, axis = (; xscale = scale)) + Colorbar(fig[1, 2], hm) + + scale = Makie.pseudolog10 + fig, ax, hm = heatmap(x, y, z; colorscale = scale, axis = (; xscale = scale)) + Colorbar(fig[1, 2], hm) +end @testset "Axis scale" begin # This just shouldn't error @@ -402,4 +427,4 @@ end @test false rethrow(e) end -end \ No newline at end of file +end