diff --git a/NEWS.md b/NEWS.md index 5064e00f31c..53847d03dcd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,9 @@ ## master +- [Breaking] Changed the default order Polar arguments to (theta, r). [#3154](https://github.com/MakieOrg/Makie.jl/pull/3154) +- General improvements to `PolarAxis`: full rlimtis & thetalimits, more controls and visual tweaks. See pr for more details.[#3154](https://github.com/MakieOrg/Makie.jl/pull/3154) + ## v0.19.9 - Allow arbitrary reversible scale functions through `ReversibleScale`. diff --git a/ReferenceTests/src/tests/examples2d.jl b/ReferenceTests/src/tests/examples2d.jl index 9692f7ec4d0..225bc4a6249 100644 --- a/ReferenceTests/src/tests/examples2d.jl +++ b/ReferenceTests/src/tests/examples2d.jl @@ -1219,7 +1219,7 @@ end @reference_test "Triplot with nonlinear transformation" begin f = Figure() ax = PolarAxis(f[1, 1]) - points = Point2f[(r, phi) for r in 1:10 for phi in range(0, 2pi, length=36)[1:35]] + points = Point2f[(phi, r) for r in 1:10 for phi in range(0, 2pi, length=36)[1:35]] tr = triplot!(ax, points) f end @@ -1303,13 +1303,13 @@ end @reference_test "Voronoiplot with a nonlinear transform" begin f = Figure() - ax = PolarAxis(f[1, 1]) + ax = PolarAxis(f[1, 1], theta_as_x = false) points = Point2f[(r, phi) for r in 1:10 for phi in range(0, 2pi, length=36)[1:35]] polygon_color = [r for r in 1:10 for phi in range(0, 2pi, length=36)[1:35]] polygon_color_2 = [phi for r in 1:10 for phi in range(0, 2pi, length=36)[1:35]] tr = voronoiplot!(ax, points, smooth = false, show_generators = false, color = polygon_color) Makie.rlims!(ax, 12) # to make rect clip visible if circular clip doesn't happen - ax = PolarAxis(f[1, 2]) + ax = PolarAxis(f[1, 2], theta_as_x = false) tr = voronoiplot!(ax, points, smooth = true, show_generators = false, color = polygon_color_2) Makie.rlims!(ax, 12) f diff --git a/ReferenceTests/src/tests/figures_and_makielayout.jl b/ReferenceTests/src/tests/figures_and_makielayout.jl index 1cb7c313c74..eba9109bdd1 100644 --- a/ReferenceTests/src/tests/figures_and_makielayout.jl +++ b/ReferenceTests/src/tests/figures_and_makielayout.jl @@ -140,8 +140,10 @@ end @reference_test "PolarAxis surface" begin f = Figure() ax = PolarAxis(f[1, 1]) - zs = [r*cos(phi) for r in range(1, 2, length=100), phi in range(0, 4pi, length=100)] - p = surface!(ax, 0..10, 0..2pi, zs, shading = false, colormap = :coolwarm, colorrange=(-2, 2)) + zs = [r*cos(phi) for phi in range(0, 4pi, length=100), r in range(1, 2, length=100)] + p = surface!(ax, 0..2pi, 0..10, zs, shading = false, colormap = :coolwarm, colorrange=(-2, 2)) + rlims!(ax, 0, 11) # verify that r = 10 doesn't end up at r > 10 + translate!(p, 0, 0, -200) Colorbar(f[1, 2], p) f end @@ -149,15 +151,14 @@ end # may fail in WGLMakie due to missing dashes @reference_test "PolarAxis scatterlines spine" begin f = Figure(resolution = (800, 400)) - ax1 = PolarAxis(f[1, 1], title = "No spine", spinevisible = false) + ax1 = PolarAxis(f[1, 1], title = "No spine", spinevisible = false, theta_as_x = false) scatterlines!(ax1, range(0, 1, length=100), range(0, 10pi, length=100), color = 1:100) ax2 = PolarAxis(f[1, 2], title = "Modified spine") ax2.spinecolor[] = :red ax2.spinestyle[] = :dash ax2.spinewidth[] = 5 - scatterlines!(ax2, range(0, 1, length=100), range(0, 10pi, length=100), color = 1:100) - + scatterlines!(ax2, range(0, 10pi, length=100), range(0, 1, length=100), color = 1:100) f end @@ -174,7 +175,7 @@ end thetaminorgridwidth = 1.0, thetaminorgridstyle = :dash, rgridwidth = 2, rgridcolor = :red, thetagridwidth = 2, thetagridcolor = :blue, - rticklabelsize = 18, rticklabelcolor = :red, + rticklabelsize = 18, rticklabelcolor = :red, rtickangle = pi/6, rticklabelstrokewidth = 1, rticklabelstrokecolor = :white, thetaticklabelsize = 18, thetaticklabelcolor = :blue, thetaticklabelstrokewidth = 1, thetaticklabelstrokecolor = :white, @@ -182,6 +183,23 @@ end f end +@reference_test "PolarAxis limits" begin + f = Figure(resolution = (800, 600)) + for (i, theta_0) in enumerate((0, -pi/6, pi/2)) + for (j, thetalims) in enumerate(((0, 2pi), (-pi/2, pi/2), (0, pi/12))) + po = PolarAxis(f[i, j], theta_0 = theta_0, thetalimits = thetalims, rlimits = (1 + 2(j-1), 7)) + po.scene.backgroundcolor[] = RGBAf(1,0.5,0.5,1) + lines!(po, range(0, 20pi, length=201), range(0, 10, length=201), color = :white, linewidth = 5) + + b = Box(f[i, j], color = (:blue, 0.2)) + translate!(b.blockscene, 0, 0, 9001) + end + end + colgap!(f.layout, 5) + rowgap!(f.layout, 5) + f +end + @reference_test "Axis3 axis reversal" begin f = Figure(resolution = (1000, 1000)) revstr(dir, rev) = rev ? "$dir rev" : "" @@ -241,4 +259,4 @@ end hidedecorations!(ax) axislegend(ax) fig -end +end \ No newline at end of file diff --git a/docs/reference/blocks/polaraxis.md b/docs/reference/blocks/polaraxis.md index 0b1fac650c0..1ca6d919833 100644 --- a/docs/reference/blocks/polaraxis.md +++ b/docs/reference/blocks/polaraxis.md @@ -1,8 +1,7 @@ # PolarAxis -The `PolarAxis` is an axis for data in polar coordinates `(radius, angle)`. It -is currently an experimental feature, meaning that some functionality might be -missing or broken, and that the `PolarAxis` is (more) open to breaking changes. +The `PolarAxis` is an axis for data given in polar coordinates, i.e a radius and an angle. +It is currently an experimental feature, meaning that some functionality might be missing or broken, and that the `PolarAxis` is (more) open to breaking changes. ## Creating a PolarAxis @@ -23,29 +22,79 @@ f ## Plotting into an PolarAxis -Like with an `Axis` you can use mutating 2D plot functions directly on a -`PolarAxis`. The input arguments of the plot functions will then be interpreted -in polar coordinates, i.e. as a radius and angle (in radians). +Like with an `Axis` you can use mutating 2D plot functions directly on a `PolarAxis`. +The input arguments of the plot functions will then be interpreted in polar coordinates, i.e. as an angle (in radians) and a radius. +The order of a arguments can be changed with `ax.theta_as_x`. \begin{examplefigure}{svg = true} ```julia -lineobject = lines!(ax, 0..10, sin, color = :red) -scatobject = scatter!(0:0.5:10, cos, color = :orange) +f = Figure(resolution = (800, 400)) + +ax = PolarAxis(f[1, 1], title = "Theta as x") +lineobject = lines!(ax, 0..2pi, sin, color = :red) + +ax = PolarAxis(f[1, 2], title = "R as x", theta_as_x = false) +scatobject = scatter!(range(0, 10, length=100), cos, color = :orange) f ``` \end{examplefigure} -Note that not every plot type is compatible with polar transforms. For example -`image` is not as it expects to be drawn on a rectangle. `heatmap` works to a -degree in CairoMakie, but not GLMakie due to differences in the backend -implementation. -`surface` can be used as a replacement for `image` as it generates a triangle -mesh. However it also has a component in z-direction which will affect drawing -order. You can use `translate!(plot, 0, 0, z_shift)` to work around that. -As a replacement for `heatmap` you can use `voronoiplot`, which generates cells -of arbitrary shape around points given to it. Here you will generally need to -set `rlims!(ax, rmax)` yourself. +## PolarAxis Limits + +By default the PolarAxis will assume `po.rlimits[] = (0.0, nothing)` and `po.thetalimits[] = (0.0, 2pi)`, showing a full circle. +You can adjust these limits to show different cut-outs of the PolarAxis. +For example, we can limit `thetalimits` to a smaller range to generate a circle sector and further limit rmin through `rlimits` to cut out the center to an arc. + +\begin{examplefigure}{svg = true} +```julia +f = Figure(resolution = (600, 600)) + +ax = PolarAxis(f[1, 1], title = "Default") +lines!(ax, range(0, 8pi, length=300), range(0, 10, length=300)) +ax = PolarAxis(f[1, 2], title = "thetalimits", thetalimits = (-pi/6, pi/6)) +lines!(ax, range(0, 8pi, length=300), range(0, 10, length=300)) + +ax = PolarAxis(f[2, 1], title = "rlimits", rlimits = (5, 10)) +lines!(ax, range(0, 8pi, length=300), range(0, 10, length=300)) +ax = PolarAxis(f[2, 2], title = "both") +lines!(ax, range(0, 8pi, length=300), range(0, 10, length=300)) +thetalims!(ax, -pi/6, pi/6) +rlims!(ax, 5, 10) + +f +``` +\end{examplefigure} + +You can make further adjustments to the orientation of the PolarAxis by adjusting `ax.theta_0` and `ax.direction`. +These adjust how angles are interpreted by the polar transform following the formula `output_angle = direction * (input_angle + theta_0)`. + +\begin{examplefigure}{svg = true} +```julia +f = Figure() + +ax = PolarAxis(f[1, 1], title = "Reoriented Axis", theta_0 = -pi/2, direction = -1) +lines!(ax, range(0, 8pi, length=300), range(0, 10, length=300)) +thetalims!(ax, -pi/6, pi/6) +rlims!(ax, 5, 10) + +f +``` +\end{examplefigure} + +Note that by default translations in adjustments of rmin and thetalimits are blocked. +These can be unblocked by calling `autolimits!(ax[, true])` which also tells the PolarAxis to derive r- and thetalimits freely from data, or by setting `ax.fixrmin[] = false` and `ax.thetazoomlock[] = false`. + + +## Plot type compatability + +Not every plot type is compatible with the polar transform. +For example `image` is not as it expects to be drawn on a rectangle. +`heatmap` works to a degree in CairoMakie, but not GLMakie due to differences in the backend implementation. +`surface` can be used as a replacement for `image` as it generates a triangle mesh. +However it also has a component in z-direction which will affect drawing order. +You can use `translate!(plot, 0, 0, z_shift)` to work around that. +As a replacement for `heatmap` you can use `voronoiplot`, which generates cells of arbitrary shape around points given to it. Here you will generally need to set `rlims!(ax, rmax)` yourself. \begin{examplefigure}{svg = false} ```julia @@ -54,22 +103,27 @@ f = Figure(resolution = (800, 500)) ax = PolarAxis(f[1, 1], title = "Surface") rs = 0:10 phis = range(0, 2pi, 37) -cs = [r+cos(4phi) for r in rs, phi in phis] -p = surface!(ax, 0..10, 0..2pi, cs, shading = false, colormap = :coolwarm) +cs = [r+cos(4phi) for phi in phis, r in rs] +p = surface!(ax, 0..2pi, 0..10, cs, shading = false, colormap = :coolwarm) +ax.gridz[] = 100 +tightlimits!(ax) # surface plots include padding by default Colorbar(f[2, 1], p, vertical = false, flipaxis = false) ax = PolarAxis(f[1, 2], title = "Voronoi") rs = 1:10 phis = range(0, 2pi, 37)[1:36] -cs = [r+cos(4phi) for r in rs, phi in phis] -p = voronoiplot!(ax, rs, phis, cs, show_generators = false, strokewidth = 0) -Makie.rlims!(ax, 10.5) +cs = [r+cos(4phi) for phi in phis, r in rs] +p = voronoiplot!(ax, phis, rs, cs, show_generators = false, strokewidth = 0) +rlims!(ax, 0.0, 10.5) Colorbar(f[2, 2], p, vertical = false, flipaxis = false) f ``` \end{examplefigure} +Note that in order to see the grid we need to adjust its depth with `ax.gridz[] = 100` (higher z means lower depth). +The hard limits for `ax.gridz` are `(-10_000, 10_000)` with `9000` being a soft limit where axis components may order incorrectly. + ## Hiding spines and decorations For a `PolarAxis` we interpret the outer ring limitting the plotting are as the @@ -122,24 +176,65 @@ f ## Interactivity -The `PolarAxis` currently implements zooming by scrolling and allows you to -reset the view with left control + left mouse button. You can change the key -combination for resetting the view with the `reset_button` attribute, which -accepts anything `ispressed` accepts. +The `PolarAxis` currently implements zooming, translation and resetting. +Zooming is implemented via scrolling, with `ax.rzoomkey = Keyboard.r` restricting zooming to the radial direction and `ax.thetazoomkey = Keyboard.t` restring to angular zooming. +You can block zooming in the r-direction by setting `ax.rzoomlock = true` and `ax.thetazoomlock = true` for theta direction. +Furthermore you can disable zooming from changing just rmin with `ax.fixrmin = true` and adjust its speed with `ax.zoomspeed = 0.1`. + +Translations are implemented with mouse drag. +By default radial translations use `ax.r_translation_button = Mouse.right` and angular translations also use `ax.theta_translation_button = Mouse.right`. +If `ax.fixrmin = true` translation in the r direction are not allowed. +If you want to disable one of these interaction you can set corresponding button to `false`. -Note that `PolarAxis` currently does not implement the interaction itnerface +There is also an interaction for rotating the whole axis using `ax.axis_rotation_button = Keyboard.left_control & Mouse.right` and resetting the axis view uses `ax.reset_button = Keyboard.left_control & Mouse.left`, matching `Axis`. +You can adjust whether this resets the rotation of the axis with `ax.reset_axis_orientation = false`. + +Note that `PolarAxis` currently does not implement the interaction interface used by `Axis`. ## Other Notes ### Plotting outside a PolarAxis -Currently there is a scatter and poly plot outside the area of the `PolarAxis` -which clips the content to the relevant area. If you want to draw outside the -circle limiting the polar axis but still within it's scene area, you will need +Currently there are two poly plots outside the area of the `PolarAxis` +which clip the content to the relevant area. If you want to draw outside the +clip limiting the polar axis but still within it's scene area, you need to translate those plots to a z range between `9000` and `10_000` or disable clipping via the `clip` attribute. +For reference, the z values used by `PolarAxis` are `po.griddepth[] = 8999` for grid lines, 9000 for the clip polygons, 9001 for spines and 9002 for tick labels. + +### Radial Distortion + +If you have a plot with a large rmin and rmax over a wide range of angles you will end up with a narrow PolarAxis. +Consider for example: + +\begin{examplefigure}{svg = true} +```julia +fig = Figure() +ax = PolarAxis(fig[1, 1], thetalimits = (0, pi)) +lines!(ax, range(0, pi, length=100), 10 .+ sin.(0.3 .* (1:100))) +fig +``` +\end{examplefigure} + +In this case you may want to distort the r-direction to make more of your data visible. +This can be done by setting `ax.radial_distortion_threshold` to a value between 0 and 1. + +\begin{examplefigure}{svg = true} +```julia +fig = Figure() +ax = PolarAxis(fig[1, 1], thetalimits = (0, pi), radial_distortion_threshold = 0.2, rlimits = (nothing, nothing)) +lines!(ax, range(0, pi, length=100), 10 .+ sin.(0.3 .* (1:100))) +fig +``` +\end{examplefigure} + +Internally PolarAxis will check `rmin/rmax` against the set threshold. +If that ratio exceed the threshold, the polar transform is adjusted to shift all radii by some `r0` such that `(rmin - r0) / rmax - r0) == ax.radial_distortion_threshold`. +In effect this will hold the inner cutout/clip radius at a fraction of the outer radius. +Note that at `ax.radial_distortion_threshold >= 1.0` (default) this will never distort your data. + ## Attributes \attrdocs{PolarAxis} diff --git a/src/basic_recipes/voronoiplot.jl b/src/basic_recipes/voronoiplot.jl index 09dd531fdd9..89e67337043 100644 --- a/src/basic_recipes/voronoiplot.jl +++ b/src/basic_recipes/voronoiplot.jl @@ -157,7 +157,7 @@ function plot!(p::Voronoiplot{<:Tuple{<:Vector{<:Point{N}}}}) where {N} attr[:clip] = map(p, pop!(attr, :clip), p.unbounded_edge_extension_factor, transform_func_obs(p), ps) do bb, ext, tf, ps if bb === automatic && tf isa Polar - rscaled = maximum(first, ps) * (1 + ext) + rscaled = maximum(p -> p[1 + tf.theta_as_x], ps) * (1 + ext) return Circle(Point2f(0), rscaled) else return bb @@ -168,6 +168,15 @@ function plot!(p::Voronoiplot{<:Tuple{<:Vector{<:Point{N}}}}) where {N} return voronoiplot!(p, attr, vorn; transformation=transform) end +function data_limits(p::Voronoiplot{<:Tuple{<:Vector{<:Point{N}}}}) where {N} + if transform_func(p) isa Polar + iter = (to_ndim(Point3f, p, 0f0) for p in p.converted[1][]) + limits_from_transformed_points(iter) + else + limits_from_transformed_points(iterate_transformed(p)) + end +end + function plot!(p::Voronoiplot{<:Tuple{<:DelTri.VoronoiTessellation}}) generators_2f = Observable(Point2f[]) PolyType = typeof(Polygon(Point2f[], [Point2f[]])) diff --git a/src/layouting/transformation.jl b/src/layouting/transformation.jl index bf8ed3c03e1..18a640704cc 100644 --- a/src/layouting/transformation.jl +++ b/src/layouting/transformation.jl @@ -407,27 +407,40 @@ end ################################################################################ """ - Polar(theta_0::Float64 = 0.0, direction::Int = +1) + Polar(theta_0::Float64 = 0.0, direction::Int = +1, r0::Float64 = 0) This struct defines a general polar-to-cartesian transformation, i.e., ```math -(r, theta) -> (r \\cos(direction * (theta + theta_0)), r \\sin(direction * (theta + theta_0))) +(r, θ) -> ((r - r₀) ⋅ \\cos(direction ⋅ (θ + θ₀)), (r - r₀) ⋅ \\sin(direction \\cdot (θ + θ₀))) ``` where theta is assumed to be in radians. -`direction` should be either -1 or +1, and `theta_0` may be any value. +`direction` should be either -1 or +1, `r0` should be positive and `theta_0` may be any value. + +Note that for `r0 != 0` the inversion may return wrong results. """ struct Polar + theta_as_x::Bool theta_0::Float64 direction::Int - Polar(theta_0 = 0.0, direction = +1) = new(theta_0, direction) + r0::Float64 + function Polar(theta_as_x = true, theta_0 = 0.0, direction = +1, r0 = 0) + return new(theta_as_x, theta_0, direction, r0) + end end Base.broadcastable(x::Polar) = (x,) function apply_transform(trans::Polar, point::VecTypes{2, T}) where T <: Real - y, x = point[1] .* sincos((point[2] + trans.theta_0) * trans.direction) + if trans.theta_as_x + r = max(0.0, point[2] - trans.r0) + θ = trans.direction * (point[1] + trans.theta_0) + else + r = max(0.0, point[1] - trans.r0) + θ = trans.direction * (point[2] + trans.theta_0) + end + y, x = r .* sincos(θ) return Point2{T}(x, y) end @@ -444,11 +457,20 @@ function apply_transform(f::Polar, point::VecTypes{N2, T}) where {N2, T} end function inverse_transform(trans::Polar) - return Makie.PointTrans{2}() do point - typeof(point)( - hypot(point[1], point[2]), - mod(trans.direction * atan(point[2], point[1]) - trans.theta_0, 0..2pi) - ) + if trans.theta_as_x + return Makie.PointTrans{2}() do point + typeof(point)( + mod(trans.direction * atan(point[2], point[1]) - trans.theta_0, 0..2pi), + hypot(point[1], point[2]) + trans.r0 + ) + end + else + return Makie.PointTrans{2}() do point + typeof(point)( + hypot(point[1], point[2]) + trans.r0, + mod(trans.direction * atan(point[2], point[1]) - trans.theta_0, 0..2pi) + ) + end end end diff --git a/src/makielayout/MakieLayout.jl b/src/makielayout/MakieLayout.jl index 1ae4045cd0f..bd5ae2c8692 100644 --- a/src/makielayout/MakieLayout.jl +++ b/src/makielayout/MakieLayout.jl @@ -53,7 +53,7 @@ export Menu export Textbox export linkxaxes!, linkyaxes!, linkaxes! export AxisAspect, DataAspect -export autolimits!, limits!, reset_limits! +export autolimits!, limits!, reset_limits!, rlims!, thetalims! export LinearTicks, WilkinsonTicks, MultiplesTicks, IntervalsBetween, LogTicks export hidexdecorations!, hideydecorations!, hidezdecorations!, hidedecorations!, hidespines! export tight_xticklabel_spacing!, tight_yticklabel_spacing!, tight_ticklabel_spacing!, tightlimits! diff --git a/src/makielayout/blocks/polaraxis.jl b/src/makielayout/blocks/polaraxis.jl index f8f84623d69..8e5a8ec916d 100644 --- a/src/makielayout/blocks/polaraxis.jl +++ b/src/makielayout/blocks/polaraxis.jl @@ -3,11 +3,9 @@ ################################################################################ -Makie.can_be_current_axis(ax::PolarAxis) = true - -function Makie.initialize_block!(po::PolarAxis; palette=nothing) - +can_be_current_axis(ax::PolarAxis) = true +function initialize_block!(po::PolarAxis; palette=nothing) # Setup Scenes cb = po.layoutobservables.computedbbox scenearea = map(po.blockscene, cb) do cb @@ -17,39 +15,61 @@ function Makie.initialize_block!(po::PolarAxis; palette=nothing) po.scene = Scene( po.blockscene, scenearea, backgroundcolor = po.backgroundcolor, clear = true ) - map!(to_color, po.scene.backgroundcolor, po.backgroundcolor) + map!(to_color, po.scene, po.scene.backgroundcolor, po.backgroundcolor) - po.overlay = Scene(po.scene, scenearea, clear = false, backgroundcolor = :transparent) + po.overlay = Scene( + po.scene, scenearea, clear = false, backgroundcolor = :transparent, + transformation = Transformation(po.scene, transform_func = identity) + ) # Setup Cycler po.cycler = Cycler() if palette === nothing - palette = fast_deepcopy(get(po.blockscene.theme, :palette, Makie.DEFAULT_PALETTES)) + palette = fast_deepcopy(get(po.blockscene.theme, :palette, DEFAULT_PALETTES)) end po.palette = palette isa Attributes ? palette : Attributes(palette) - # Setup camera/limits - axis_radius = setup_camera_matrices!(po) - # TODO - theta_0 should affect ticks? + # Setup camera/limits and Polar transform + usable_fraction, radius_at_origin = setup_camera_matrices!(po) + Observables.connect!( po.scene.transformation.transform_func, - @lift(Polar($(po.theta_0), $(po.direction))) + @lift(Polar($(po.theta_as_x), $(po.target_theta_0), $(po.direction), $(radius_at_origin))) + ) + Observables.connect!( + po.overlay.transformation.transform_func, + @lift(Polar(false, $(po.target_theta_0), $(po.direction))) ) - # Outsource to `draw_axis` function - thetaticklabelplot = draw_axis!(po, axis_radius) + # Draw clip, grid lines, spine, ticks + rticklabelplot, thetaticklabelplot = draw_axis!(po, radius_at_origin) - # Handle tick label spacing by axis radius adjustments + # Calculate fraction of screen usable after reserving space for theta ticks + # TODO: Should we include rticks here? + # OPT: only update on relevant text attributes rather than glyphcollection onany( - po.blockscene, thetaticklabelplot.plots[1].plots[1][1], - po.thetaticklabelpad, po.overlay.px_area - ) do glyph_collections, pad, area + po.blockscene, + rticklabelplot.plots[1].text, + rticklabelplot.plots[1].fontsize, + rticklabelplot.plots[1].font, + po.rticklabelpad, + thetaticklabelplot.plots[1].text, + thetaticklabelplot.plots[1].fontsize, + thetaticklabelplot.plots[1].font, + po.thetaticklabelpad, + po.overlay.px_area + ) do _, _, _, rpad, _, _, _, tpad, area - # get maximum size of tick label (each boundingbox represents a string without text.position applied) + # get maximum size of tick label + # (each boundingbox represents a string without text.position applied) max_widths = Vec2f(0) - for gc in glyph_collections + for gc in thetaticklabelplot.plots[1].plots[1][1][] + bbox = boundingbox(gc, Quaternionf(0, 0, 0, 1)) # no rotation + max_widths = max.(max_widths, widths(bbox)[Vec(1,2)]) + end + for gc in rticklabelplot.plots[1].plots[1][1][] bbox = boundingbox(gc, Quaternionf(0, 0, 0, 1)) # no rotation max_widths = max.(max_widths, widths(bbox)[Vec(1,2)]) end @@ -57,18 +77,58 @@ function Makie.initialize_block!(po::PolarAxis; palette=nothing) max_width, max_height = max_widths space_from_center = 0.5 .* widths(area) - space_for_ticks = 2pad .+ (max_width, max_height) + space_for_ticks = 2max(rpad, tpad) .+ (max_width, max_height) space_for_axis = space_from_center .- space_for_ticks - axis_radius[] = max(0, minimum(space_for_axis) / space_from_center[1]) + + # divide by width only because aspect ratios + usable_fraction[] = space_for_axis ./ space_from_center[1] end # Set up the title position title_position = map( - po.blockscene, scenearea, po.titlegap, po.titlealign - ) do area, titlegap, titlealign - calculate_polar_title_position(area, titlegap, titlealign) + po.blockscene, + po.target_rlims, po.target_thetalims, po.target_theta_0, po.direction, + po.rticklabelsize, po.rticklabelpad, + po.thetaticklabelsize, po.thetaticklabelpad, + po.overlay.px_area, po.overlay.camera.projectionview, + po.titlegap, po.titlesize, po.titlealign + ) do rlims, thetalims, theta_0, dir, r_fs, r_pad, t_fs, t_pad, area, pv, gap, size, align + + # derive y position + # transform to pixel space + w, h = widths(area) + m = 0.5h * pv[2, 2] + b = 0.5h * (pv[2, 4] + 1) + + thetamin, thetamax = extrema(dir .* (thetalims .+ theta_0)) + if thetamin - div(thetamax - 0.5pi, 2pi, RoundDown) * 2pi < 0.5pi + # clip at 1 in overlay scene + # theta fontsize & pad relevant + ypx = (m * 1.0 + b) + (2t_pad + t_fs + gap) + else + y1 = sin(thetamin); y2 = sin(thetamax) + rscale = rlims[1] / rlims[2] + y = max(rscale * y1, rscale * y2, y1, y2) + ypx = (m * y + b) + (2max(t_pad, r_pad) + max(t_fs, r_fs) + gap) + end + + xpx::Float32 = if align === :center + area.origin[1] + w / 2 + elseif align === :left + area.origin[1] + elseif align === :right + area.origin[1] + w + elseif align isa Real + area.origin[1] + align * w + else + error("Title align $align not supported.") + end + + return Point2f(xpx, area.origin[2] + ypx) end + # p = scatter!(po.blockscene, title_position, color = :red, overdraw = true) + # translate!(p, 0, 0, 9100) titleplot = text!( po.blockscene, title_position; @@ -76,21 +136,19 @@ function Makie.initialize_block!(po::PolarAxis; palette=nothing) font = po.titlefont, fontsize = po.titlesize, color = po.titlecolor, - align = @lift(($(po.titlealign), :center)), - visible = po.titlevisible + align = @lift(($(po.titlealign), :bottom)), + visible = po.titlevisible, ) - translate!(titleplot, 0, 0, 9001) # Make sure this draws on top of clip + translate!(titleplot, 0, 0, 9100) # Make sure this draws on top of clip # Protrusions are space reserved for ticks and labels outside `scenearea`. - # Since we handle ticks within out `scenearea` this only needs to reservse + # Since we handle ticks within out `scenearea` this only needs to reserve # space for the title protrusions = map( - po.blockscene, po.title, po.titlefont, po.titlegap, po.titlealign, po.titlevisible, po.titlesize - ) do _, _, _, _, _, _ - GridLayoutBase.RectSides( - 0f0, 0f0, 0f0, - (title_position[][2] + boundingbox(titleplot).widths[2]/2 - top(pixelarea(po.scene)[])), - ) + po.blockscene, po.title, po.titlegap, po.titlesize + ) do title, gap, size + titlespace = po.title[] == "" ? 0f0 : Float32(2gap + size) + return GridLayoutBase.RectSides(0f0, 0f0, 0f0, titlespace) end connect!(po.layoutobservables.protrusions, protrusions) @@ -98,51 +156,485 @@ function Makie.initialize_block!(po::PolarAxis; palette=nothing) return end -function draw_axis!(po::PolarAxis, axis_radius) - thetalims = (0, 2pi) +################################################################################ +### Camera and Controls +################################################################################ + + +function polar2cartesian(r, angle) + s, c = sincos(angle) + return Point2f(r * c, r * s) +end + +# Bounding box specified by limits in Cartesian coordinates +function polaraxis_bbox(rlims, thetalims, r0, dir, theta_0) + thetamin, thetamax = thetalims + rmin, rmax = max.(0.0, rlims .- r0) + + if abs(thetamax - thetamin) > 3pi/2 + return Rect2f(-rmax, -rmax, 2rmax, 2rmax) + end + + @assert thetamin < thetamax # otherwise shift by 2pi I guess + thetamin, thetamax = dir .* (thetalims .+ theta_0) + + # Normalize angles + # keep interval in order + if thetamin > thetamax + thetamin, thetamax = thetamax, thetamin + end + # keep in -2pi .. 2pi interval + shift = 2pi * (max(0, div(thetamin, -2pi)) - max(0, div(thetamax, 2pi))) + thetamin += shift + thetamax += shift + + # Initial bbox from corners + p = polar2cartesian(rmin, thetamin) + bb = Rect2f(p, Vec2f(0)) + bb = _update_rect(bb, polar2cartesian(rmax, thetamin)) + bb = _update_rect(bb, polar2cartesian(rmin, thetamax)) + bb = _update_rect(bb, polar2cartesian(rmax, thetamax)) + + # only outer circle can update bb + if thetamin < -3pi/2 < thetamax || thetamin < pi/2 < thetamax + bb = _update_rect(bb, polar2cartesian(rmax, pi/2)) + end + if thetamin < -pi < thetamax || thetamin < pi < thetamax + bb = _update_rect(bb, polar2cartesian(rmax, pi)) + end + if thetamin < -pi/2 < thetamax || thetamin < 3pi/2 < thetamax + bb = _update_rect(bb, polar2cartesian(rmax, 3pi/2)) + end + if thetamin < 0 < thetamax + bb = _update_rect(bb, polar2cartesian(rmax, 0)) + end + + return bb +end + +function setup_camera_matrices!(po::PolarAxis) + # Initialization + usable_fraction = Observable(Vec2f(1.0, 1.0)) + setfield!(po, :target_rlims, Observable{Tuple{Float64, Float64}}((0.0, 10.0))) + setfield!(po, :target_thetalims, Observable{Tuple{Float64, Float64}}((0.0, 2pi))) + setfield!(po, :target_theta_0, map(identity, po.theta_0)) + reset_limits!(po) + onany((_, _) -> reset_limits!(po), po.blockscene, po.rlimits, po.thetalimits) + + # To keep the inner clip radius below a certain fraction of the outer clip + # radius we map all r > r0 to 0. This computes that r0. + radius_at_origin = map(po.blockscene, po.target_rlims, po.radial_distortion_threshold) do (rmin, rmax), max_fraction + # max_fraction = (rmin - r0) / (rmax - r0) solved for r0 + return max(0.0, (rmin - max_fraction * rmax) / (1 - max_fraction)) + end + + # get cartesian bbox defined by axis limits + # OPT: target_radius update triggers radius_at_origin update + data_bbox = map(po.blockscene, po.target_thetalims, radius_at_origin, po.direction, po.target_theta_0) do tlims, ro, dir, t0 + return polaraxis_bbox(po.target_rlims[], tlims, ro, dir, t0) + end + + # fit data_bbox into the usable area of PolarAxis (i.e. with tick space subtracted) + onany(po.blockscene, usable_fraction, data_bbox) do usable_fraction, bb + mini = minimum(bb); ws = widths(bb) + scale = minimum(2usable_fraction ./ ws) + trans = to_ndim(Vec3f, -scale .* (mini .+ 0.5ws), 0) + camera(po.scene).view[] = transformationmatrix(trans, Vec3f(scale, scale, 1)) + return + end + + # same as above, but with rmax scaled to 1 + onany(po.blockscene, usable_fraction, data_bbox) do usable_fraction, bb + mini = minimum(bb); ws = widths(bb) + rmax = po.target_rlims[][2] - radius_at_origin[] # both update data_bbox + scale = minimum(2usable_fraction ./ ws) + trans = to_ndim(Vec3f, -scale .* (mini .+ 0.5ws), 0) + scale *= rmax + camera(po.overlay).view[] = transformationmatrix(trans, Vec3f(scale, scale, 1)) + end + + max_z = 10_000f0 + + # update projection matrices + # this just aspect-aware clip space (-1 .. 1, -h/w ... h/w, -max_z ... max_z) + on(po.blockscene, po.scene.px_area) do area + aspect = Float32((/)(widths(area)...)) + w = 1f0 + h = 1f0 / aspect + camera(po.scene).projection[] = orthographicprojection(-w, w, -h, h, -max_z, max_z) + end + + on(po.blockscene, po.overlay.px_area) do area + aspect = Float32((/)(widths(area)...)) + w = 1f0 + h = 1f0 / aspect + camera(po.overlay).projection[] = orthographicprojection(-w, w, -h, h, -max_z, max_z) + end + + # Interactivity + e = events(po.scene) + + # scroll to zoom + on(po.blockscene, e.scroll) do scroll + if is_mouseinside(po.scene) && (!po.rzoomlock[] || !po.thetazoomlock[]) + mp = mouseposition(po.scene) + r = norm(mp) + zoom_scale = (1.0 - po.zoomspeed[]) ^ scroll[2] + rmin, rmax = po.target_rlims[] + thetamin, thetamax = po.target_thetalims[] + + # keep circumference to radial length ratio constant by default + dtheta = thetamax - thetamin + aspect = r * dtheta / (rmax - rmin) + + # change in radial length + dr = (zoom_scale - 1) * (rmax - rmin) + + # to keep the point under the cursor roughly in place we keep + # r at the same percentage between rmin and rmax + w = (r - rmin) / (rmax - rmin) + + # keep rmin at 0 when zooming near zero + if rmin != 0 || r > 0.1rmax + rmin = max(0, rmin - w * dr) + end + rmax = max(rmin + 100eps(rmin), rmax + (1 - w) * dr) + + if !ispressed(e, po.thetazoomkey[]) && !po.rzoomlock[] + if po.fixrmin[] + rmin = po.target_rlims[][1] + rmax = max(rmin + 100eps(rmin), rmax + dr) + end + po.target_rlims[] = (rmin, rmax) + end + + if abs(thetamax - thetamin) < 2pi + + # angle of mouse position normalized to range + theta = po.direction[] * atan(mp[2], mp[1]) - po.target_theta_0[] + thetacenter = 0.5 * (thetamin + thetamax) + theta = mod(theta, thetacenter-pi .. thetacenter+pi) + + w = (theta - thetamin) / (thetamax - thetamin) + dtheta = (thetamax - thetamin) - clamp(aspect * (rmax - rmin) / r, 0, 2pi) + thetamin = thetamin + w * dtheta + thetamax = thetamax - (1-w) * dtheta + + if !ispressed(e, po.rzoomkey[]) && !po.thetazoomlock[] + if po.normalize_theta_ticks[] + if thetamax - thetamin < 2pi - 1e-5 + po.target_thetalims[] = normalize_thetalims(thetamin, thetamax) + else + po.target_thetalims[] = (0.0, 2pi) + end + else + po.target_thetalims[] = (thetamin, thetamax) + end + end + + # don't open a gap when zooming a full circle near the center + elseif r > 0.1rmax && zoom_scale < 1 + + # open angle on the opposite site of theta + theta = po.direction[] * atan(mp[2], mp[1]) - po.target_theta_0[] + theta = theta + pi + thetamin # (-pi, pi) -> (thetamin, thetamin+2pi) + + dtheta = (thetamax - thetamin) - clamp(aspect * (rmax - rmin) / r, 1e-6, 2pi) + thetamin = theta + 0.5 * dtheta + thetamax = theta + 2pi - 0.5 * dtheta + + if !ispressed(e, po.rzoomkey[]) && !po.thetazoomlock[] + if po.normalize_theta_ticks[] + po.target_thetalims[] = normalize_thetalims(thetamin, thetamax) + else + po.target_thetalims[] = (thetamin, thetamax) + end + end + end + + return Consume(true) + end + return Consume(false) + end + + # translation + drag_state = RefValue((false, false, false)) + last_pos = RefValue(Point2f(0)) + last_px_pos = RefValue(Point2f(0)) + on(po.blockscene, e.mousebutton) do e + if e.action == Mouse.press && is_mouseinside(po.scene) + drag_state[] = ( + ispressed(po.scene, po.r_translation_button[]), + ispressed(po.scene, po.theta_translation_button[]), + ispressed(po.scene, po.axis_rotation_button[]) + ) + last_px_pos[] = Point2f(mouseposition_px(po.scene)) + last_pos[] = Point2f(mouseposition(po.scene)) + return Consume(any(drag_state[])) + elseif e.action == Mouse.release + was_pressed = any(drag_state[]) + drag_state[] = ( + ispressed(po.scene, po.r_translation_button[]), + ispressed(po.scene, po.theta_translation_button[]), + ispressed(po.scene, po.axis_rotation_button[]) + ) + return Consume(was_pressed) + end + return Consume(false) + end + + on(po.blockscene, e.mouseposition) do _ + if drag_state[][3] + w = widths(po.scene.px_area[]) + p0 = (last_px_pos[] .- 0.5w) ./ w + p1 = Point2f(mouseposition_px(po.scene) .- 0.5w) ./ w + if norm(p0) * norm(p1) < 1e-6 + Δθ = 0.0 + else + Δθ = mod(po.direction[] * (atan(p1[2], p1[1]) - atan(p0[2], p0[1])), -pi..pi) + end + + po.target_theta_0[] = mod(po.target_theta_0[] + Δθ, 0..2pi) + + last_px_pos[] = Point2f(mouseposition_px(po.scene)) + last_pos[] = Point2f(mouseposition(po.scene)) + + elseif drag_state[][1] || drag_state[][2] + pos = Point2f(mouseposition(po.scene)) + diff = pos - last_pos[] + r = norm(last_pos[]) + + if r < 1e-6 + Δr = norm(pos) + Δθ = 0.0 + else + u_r = last_pos[] ./ r + u_θ = Point2f(-u_r[2], u_r[1]) + Δr = dot(u_r, diff) + Δθ = po.direction[] * dot(u_θ, diff ./ r) + end + + if drag_state[][1] && !po.fixrmin[] + rmin, rmax = po.target_rlims[] + dr = min(rmin, Δr) + po.target_rlims[] = (rmin - dr, rmax - dr) + end + if drag_state[][2] + thetamin, thetamax = po.target_thetalims[] + if thetamax - thetamin > 2pi - 1e-5 + # full circle -> rotate view + po.target_theta_0[] = mod(po.target_theta_0[] + Δθ, 0..2pi) + else + # partial circle -> rotate and adjust limits + thetamin, thetamax = (thetamin, thetamax) .- Δθ + if po.normalize_theta_ticks[] + po.target_thetalims[] = normalize_thetalims(thetamin, thetamax) + else + po.target_thetalims[] = (thetamin, thetamax) + end + po.target_theta_0[] = mod(po.target_theta_0[] + Δθ, 0..2pi) + end + end + + # Needs recomputation because target_radius may have changed + last_px_pos[] = Point2f(mouseposition_px(po.scene)) + last_pos[] = Point2f(mouseposition(po.scene)) + return Consume(true) + end + return Consume(false) + end + + # Reset button + onany(po.blockscene, e.mousebutton, e.keyboardbutton) do e1, e2 + if ispressed(e, po.reset_button[]) && is_mouseinside(po.scene) && + (e1.action == Mouse.press) && (e2.action == Keyboard.press) + old_thetalims = po.target_thetalims[] + if ispressed(e, Keyboard.left_shift) + autolimits!(po) + else + reset_limits!(po) + end + if po.reset_axis_orientation[] + notify(po.theta_0) + else + diff = 0.5 * sum(po.target_thetalims[] .- old_thetalims) + po.target_theta_0[] = mod(po.target_theta_0[] - diff, 0..2pi) + end + return Consume(true) + end + return Consume(false) + end + + return usable_fraction, radius_at_origin +end + +function reset_limits!(po::PolarAxis) + # at least one derived limit + if any(isnothing, po.rlimits[]) || any(isnothing, po.thetalimits[]) + if !isempty(po.scene.plots) + # TODO: Why does this include child scenes by default? + + # Generate auto limits + lims2d = Rect2f(data_limits(po.scene, p -> !(p in po.scene.plots))) + + if po.theta_as_x[] + thetamin, rmin = minimum(lims2d) + thetamax, rmax = maximum(lims2d) + else + rmin, thetamin = minimum(lims2d) + rmax, thetamax = maximum(lims2d) + end + + # cleanup autolimits (0 width, negative rmin) + if rmin == rmax + rmin = max(0.0, rmin - 5.0) + rmax = rmin + 10.0 + else + dr = rmax - rmin + rmin = max(0.0, rmin - po.rautolimitmargin[][1] * dr) + rmax += po.rautolimitmargin[][2] * dr + end + + dtheta = thetamax - thetamin + if thetamin == thetamax + thetamin, thetamax = (0.0, 2pi) + elseif dtheta > 1.5pi + thetamax = thetamin + 2pi + else + thetamin -= po.thetaautolimitmargin[][1] * dtheta + thetamax += po.thetaautolimitmargin[][2] * dtheta + end + + else + # no plot limits, use defaults + rmin = 0.0; rmax = 10.0; thetamin = 0.0; thetamax = 2pi + end + + # apply + po.target_rlims[] = ifelse.(isnothing.(po.rlimits[]), (rmin, rmax), po.rlimits[]) + po.target_thetalims[] = ifelse.(isnothing.(po.thetalimits[]), (thetamin, thetamax), po.thetalimits[]) + else # all limits set + if po.target_rlims[] != po.rlimits[] + po.target_rlims[] = po.rlimits[] + end + if po.target_thetalims[] != po.thetalimits[] + po.target_thetalims[] = po.thetalimits[] + end + end + + return +end + + +################################################################################ +### Axis visualization - grid lines, clip, ticks +################################################################################ + + +# generates large square with circle sector cutout +function _polar_clip_polygon( + thetamin, thetamax, steps = 120, outer = 1e4, + exterior = Point2f[(-outer, -outer), (-outer, outer), (outer, outer), (outer, -outer), (-outer, -outer)] + ) + # make sure we have 2+ points per arc + interior = map(theta -> polar2cartesian(1.0, theta), LinRange(thetamin, thetamax, steps)) + (abs(thetamax - thetamin) ≈ 2pi) || push!(interior, Point2f(0)) + return [Polygon(exterior, [interior])] +end + +function draw_axis!(po::PolarAxis, radius_at_origin) rtick_pos_lbl = Observable{Vector{<:Tuple{AbstractString, Point2f}}}() - rgridpoints = Observable{Vector{Makie.GeometryBasics.LineString}}() - rminorgridpoints = Observable{Vector{Makie.GeometryBasics.LineString}}() + rtick_align = Observable{Point2f}() + rtick_offset = Observable{Point2f}() + rtick_rotation = Observable{Float32}() + rgridpoints = Observable{Vector{GeometryBasics.LineString}}() + rminorgridpoints = Observable{Vector{GeometryBasics.LineString}}() + + function default_rtickangle(rtickangle, direction, thetalims) + if rtickangle === automatic + if direction == -1 + return thetalims[2] + else + return thetalims[1] + end + else + return rtickangle + end + end + # OPT: target_radius update triggers radius_at_origin update onany( po.blockscene, - po.rticks, po.rminorticks, po.rtickformat, po.rticklabelpad, - po.rtickangle, po.target_radius, axis_radius, po.sample_density, - po.overlay.px_area - ) do rticks, rminorticks, rtickformat, px_pad, rtickangle, data_radius, axis_radius, sample_density, pixelarea - - _rtickvalues, _rticklabels = Makie.get_ticks(rticks, identity, rtickformat, 0, data_radius) - _rtickpos = _rtickvalues .* (axis_radius / data_radius) # we still need the values - pad = let - w2, h2 = (0.5 .* widths(pixelarea)).^2 - s, c = sincos(rtickangle) - axis_radius * px_pad / sqrt(w2 * c * c + h2 * s * s) - end - rtick_pos_lbl[] = tuple.(_rticklabels, Point2f.(_rtickpos .+ pad, rtickangle)) - + po.rticks, po.rminorticks, po.rtickformat, po.rtickangle, + po.direction, po.target_thetalims, po.sample_density, radius_at_origin + ) do rticks, rminorticks, rtickformat, rtickangle, + dir, thetalims, sample_density, radius_at_origin + + # For text: + rlims = po.target_rlims[] + rmaxinv = 1.0 / (rlims[2] - radius_at_origin) + _rtickvalues, _rticklabels = get_ticks(rticks, identity, rtickformat, rlims...) + _rtickradius = (_rtickvalues .- radius_at_origin) .* rmaxinv + _rtickangle = default_rtickangle(rtickangle, dir, thetalims) + rtick_pos_lbl[] = tuple.(_rticklabels, Point2f.(_rtickradius, _rtickangle)) + + # For grid lines thetas = LinRange(thetalims..., sample_density) - rgridpoints[] = Makie.GeometryBasics.LineString.([Point2f.(r, thetas) for r in _rtickpos]) + rgridpoints[] = GeometryBasics.LineString.([Point2f.(r, thetas) for r in _rtickradius]) - _rminortickvalues = Makie.get_minor_tickvalues(rminorticks, identity, _rtickvalues, 0, data_radius) - _rminortickvalues .*= (axis_radius / data_radius) - rminorgridpoints[] = Makie.GeometryBasics.LineString.([Point2f.(r, thetas) for r in _rminortickvalues]) + _rminortickvalues = get_minor_tickvalues(rminorticks, identity, _rtickvalues, rlims...) + _rminortickvalues .= (_rminortickvalues .- radius_at_origin) .* rmaxinv + rminorgridpoints[] = GeometryBasics.LineString.([Point2f.(r, thetas) for r in _rminortickvalues]) return end + # doesn't have a lot of overlap with the inputs above so calculate this independently + onany( + po.blockscene, + po.direction, po.target_theta_0, po.rtickangle, po.target_thetalims, po.rticklabelpad, + po.rticklabelrotation + ) do dir, theta_0, rtickangle, thetalims, pad, rot + angle = mod(dir * (default_rtickangle(rtickangle, dir, thetalims) + theta_0), 0..2pi) + s, c = sincos(angle - pi/2) + rtick_offset[] = Point2f(pad * c, pad * s) + if rot === automatic + rot = (thetalims[2] - thetalims[1]) > 1.9pi ? (:horizontal) : (:aligned) + end + if rot === :horizontal + rtick_rotation[] = 0f0 + scale = 1 / max(abs(s), abs(c)) # point on ellipse -> point on bbox + rtick_align[] = Point2f(0.5 - 0.5scale * c, 0.5 - 0.5scale * s) + elseif rot === :radial + rtick_rotation[] = angle - pi/2 + rtick_align[] = Point2f(0, 0.5) + elseif rot === :aligned + N = div(angle + pi/4, pi/2) % 4 + rtick_rotation[] = angle - (N) * pi/2 # mod(angle, -pi/4 .. pi/4) + rtick_align[] = Point2f((0.5, 0.0, 0.5, 1.0)[N+1], (1.0, 0.5, 0.0, 0.5)[N+1]) + elseif rot isa Real + rtick_rotation[] = rot + scale = 1 / max(abs(s), abs(c)) + rtick_align[] = Point2f(0.5 - 0.5scale * c, 0.5 - 0.5scale * s) + end + return + end + + thetatick_pos_lbl = Observable{Vector{<:Tuple{AbstractString, Point2f}}}() - thetatick_align = Observable{Vector{Point2f}}() + thetatick_align = Observable(Point2f[]) + thetatick_offset = Observable(Point2f[]) thetagridpoints = Observable{Vector{Point2f}}() thetaminorgridpoints = Observable{Vector{Point2f}}() onany( po.blockscene, po.thetaticks, po.thetaminorticks, po.thetatickformat, po.thetaticklabelpad, - po.direction, po.theta_0, axis_radius, po.overlay.px_area - ) do thetaticks, thetaminorticks, thetatickformat, px_pad, dir, theta_0, axis_radius, pixelarea + po.direction, po.target_theta_0, po.target_rlims, po.target_thetalims, po.radial_distortion_threshold + ) do thetaticks, thetaminorticks, thetatickformat, px_pad, dir, theta_0, rlims, thetalims, max_clip - _thetatickvalues, _thetaticklabels = Makie.get_ticks(thetaticks, identity, thetatickformat, 0, 2pi) + _thetatickvalues, _thetaticklabels = get_ticks(thetaticks, identity, thetatickformat, thetalims...) # Since theta = 0 is at the same position as theta = 2π, we remove the last tick # iff the difference between the first and last tick is exactly 2π @@ -152,52 +644,33 @@ function draw_axis!(po::PolarAxis, axis_radius) pop!(_thetaticklabels) end - thetatick_align[] = map(_thetatickvalues) do angle - return angle2align(dir * (angle + theta_0) + pi) - end - - # transform px_pad to radial pad - w2, h2 = (0.5 .* widths(pixelarea)).^2 - tick_positions = map(_thetatickvalues) do angle - s, c = sincos(angle) - pad_mult = 1.0 + px_pad / sqrt(w2 * c * c + h2 * s * s) - Point2f(pad_mult * axis_radius, angle) + # Text + resize!(thetatick_align.val, length(_thetatickvalues)) + resize!(thetatick_offset.val, length(_thetatickvalues)) + for (i, angle) in enumerate(_thetatickvalues) + s, c = sincos(dir * (angle + theta_0)) + scale = 1 / max(abs(s), abs(c)) # point on ellipse -> point on bbox + thetatick_align.val[i] = Point2f(0.5 - 0.5scale * c, 0.5 - 0.5scale * s) + thetatick_offset.val[i] = Point2f(px_pad * c, px_pad * s) end + foreach(notify, (thetatick_align, thetatick_offset)) - thetatick_pos_lbl[] = tuple.(_thetaticklabels, tick_positions) + thetatick_pos_lbl[] = tuple.(_thetaticklabels, Point2f.(1, _thetatickvalues)) - thetagridpoints[] = [Point2f(r, theta) for theta in _thetatickvalues for r in (0, axis_radius)] + # Grid lines + rmin = min(rlims[1] / rlims[2], max_clip) + thetagridpoints[] = [Point2f(r, theta) for theta in _thetatickvalues for r in (rmin, 1)] - _thetaminortickvalues = Makie.get_minor_tickvalues(thetaminorticks, identity, _thetatickvalues, thetalims...) - thetaminorgridpoints[] = [Point2f(r, theta) for theta in _thetaminortickvalues for r in (0, axis_radius)] + _thetaminortickvalues = get_minor_tickvalues(thetaminorticks, identity, _thetatickvalues, thetalims...) + thetaminorgridpoints[] = [Point2f(r, theta) for theta in _thetaminortickvalues for r in (rmin, 1)] return end - spinepoints = Observable{Vector{Point2f}}() - - onany( - po.blockscene, po.sample_density, axis_radius - ) do sample_density, axis_radius - - thetas = LinRange(thetalims..., sample_density) - spinepoints[] = Point2f.(axis_radius, thetas) - - return - end - - # TODO - compute this based on text bb (which would replace this trigger) - notify(axis_radius) + notify(po.target_thetalims) # plot using the created observables - # spine - spineplot = lines!( - po.overlay, spinepoints; - color = po.spinecolor, - linestyle = po.spinestyle, - linewidth = po.spinewidth, - visible = po.spinevisible, - ) + # major grids rgridplot = lines!( po.overlay, rgridpoints; @@ -233,17 +706,12 @@ function draw_axis!(po::PolarAxis, axis_radius) # tick labels - clipcolor = map(po.blockscene, po.backgroundcolor) do bgc - bgc = to_color(bgc) - if alpha(bgc) == 0f0 - return to_color(:white) - else - return bgc - end + clipcolor = map(po.blockscene, po.clipcolor, po.backgroundcolor) do cc, bgc + return cc === automatic ? RGBf(to_color(bgc)) : RGBf(to_color(cc)) end rstrokecolor = map(po.blockscene, clipcolor, po.rticklabelstrokecolor) do bg, sc - sc === automatic ? bg : Makie.to_color(sc) + sc === automatic ? bg : to_color(sc) end rticklabelplot = text!( @@ -253,14 +721,16 @@ function draw_axis!(po::PolarAxis, axis_radius) color = po.rticklabelcolor, strokewidth = po.rticklabelstrokewidth, strokecolor = rstrokecolor, - visible = po.rticklabelsvisible, - align = map(po.direction, po.theta_0, po.rtickangle) do dir, theta_0, angle - return angle2align(dir * (angle + theta_0) + pi) - end + align = rtick_align, + rotation = rtick_rotation, + visible = po.rticklabelsvisible ) + # OPT: skip glyphcollection update on offset changes + rticklabelplot.plots[1].plots[1].offset = rtick_offset + thetastrokecolor = map(po.blockscene, clipcolor, po.thetaticklabelstrokecolor) do bg, sc - sc === automatic ? bg : Makie.to_color(sc) + sc === automatic ? bg : to_color(sc) end thetaticklabelplot = text!( @@ -273,87 +743,118 @@ function draw_axis!(po::PolarAxis, axis_radius) align = thetatick_align[], visible = po.thetaticklabelsvisible ) + thetaticklabelplot.plots[1].plots[1].offset = thetatick_offset # Hack to deal with synchronous update problems - on(thetatick_align) do align + on(po.blockscene, thetatick_align) do align thetaticklabelplot.align.val = align + if length(align) == length(thetatick_pos_lbl[]) + notify(thetaticklabelplot.align) + end + return end - # inner clip - # scatter shouldn't interfere with lines and text in GLMakie, so this should - # look a bit cleaner - inverse_circle = BezierPath([ - MoveTo(Point( 1, 1)), - LineTo(Point( 1, -1)), - LineTo(Point(-1, -1)), - LineTo(Point(-1, 1)), - MoveTo(Point(1, 0)), - EllipticalArc(Point(0.0, 0), 0.5, 0.5, 0.0, 0.0, 2pi), - ClosePath(), - ]) - - ms = map( - (rect, radius) -> radius * widths(rect)[1], - po.blockscene, po.overlay.px_area, axis_radius - ) + # Clipping - clipplot = scatter!( + # create large square with r=1 circle sector cutout + # only regenerate if circle sector angle changes + thetadiff = map(lims -> abs(lims[2] - lims[1]), po.blockscene, po.target_thetalims, ignore_equal_values = true) + outer_clip = map(po.blockscene, thetadiff, po.sample_density) do diff, sample_density + return _polar_clip_polygon(0, diff, sample_density) + end + outer_clip_plot = poly!( po.overlay, - Point2f(0), + outer_clip, color = clipcolor, - markersize = ms, - marker = inverse_circle, visible = po.clip, + fxaa = false, + transformation = Transformation(), # no polar transform for this + shading = false ) - # outer clip - # for when aspect ratios get extreme (> 2) or the axis very small - clippoints = let - v = 1000 # should keep `scene` covered up to this aspect ratio - exterior = Point2f[(-v, -v), (v, -v), (v, v), (-v, v)] - v = 0.99 # at edge of scattered marker (slightly less because of AA) - interior = Point2f[(-v, -v), (v, -v), (v, v), (-v, v)] - GeometryBasics.Polygon(exterior, [interior]) + # inner clip is a (filled) circle sector which also needs to regenerate with + # changes in thetadiff + inner_clip = map(po.blockscene, thetadiff, po.sample_density) do diff, sample_density + pad = diff / sample_density + if diff > 2pi - 2pad + ps = polar2cartesian.(1.0, LinRange(0, 2pi, sample_density)) + else + ps = polar2cartesian.(1.0, LinRange(-pad, diff + pad, sample_density)) + push!(ps, Point2f(0)) + end + return Polygon(ps) end - - clipouter = poly!( + inner_clip_plot = poly!( po.overlay, - clippoints, + inner_clip, color = clipcolor, visible = po.clip, fxaa = false, - transformation = Transformation() # no polar transform for this + transformation = Transformation(), + shading = false ) - on(po.blockscene, axis_radius) do radius - scale!(clipouter, 2 * Vec3f(radius, radius, 1)) - end - - translate!.((spineplot, rgridplot, thetagridplot, rminorgridplot, thetaminorgridplot, rticklabelplot, thetaticklabelplot), 0, 0, 9000) - translate!.((clipplot, clipouter), 0, 0, 8990) - - return thetaticklabelplot -end -function calculate_polar_title_position(area, titlegap, align) - w, h = area.widths + # handle placement with transform + onany(po.blockscene, po.target_thetalims, po.direction, po.target_theta_0) do thetalims, dir, theta_0 + thetamin, thetamax = dir .* (thetalims .+ theta_0) + angle = dir > 0 ? thetamin : thetamax + rotate!.((outer_clip_plot, inner_clip_plot), (Vec3f(0,0,1),), angle) + end - align_factor = halign2num(align, "Horizontal title align $align not supported.") - x::Float32 = area.origin[1] + align_factor * w + onany(po.blockscene, po.target_rlims, po.radial_distortion_threshold) do lims, maxclip + s = min(lims[1] / lims[2], maxclip) + scale!(inner_clip_plot, Vec3f(s, s, 1)) + end - # local subtitlespace::Float32 = if ax.subtitlevisible[] && !iswhitespace(ax.subtitle[]) - # boundingbox(subtitlet).widths[2] + subtitlegap - # else - # 0f0 - # end + notify(po.radial_distortion_threshold) + + # spine traces circle sector - inner circle + spine_points = map(po.blockscene, + po.target_rlims, po.target_thetalims, po.radial_distortion_threshold, po.sample_density + ) do (rmin, rmax), thetalims, max_clip, N + thetamin, thetamax = thetalims + rmin = min(rmin/rmax, max_clip) + rmax = 1.0 + + # make sure we have 2+ points per arc + if abs(thetamax - thetamin) ≈ 2pi + ps = Point2f.(rmax, LinRange(thetamin, thetamax, N)) + if rmin > 1e-6 + push!(ps, Point2f(NaN)) + append!(ps, Point2f.(rmin, LinRange(thetamin, thetamax, N))) + end + else + ps = sizehint!(Point2f[], 2N+1) + for angle in LinRange(thetamin, thetamax, N) + push!(ps, Point2f(rmin, angle)) + end + for angle in LinRange(thetamax, thetamin, N) + push!(ps, Point2f(rmax, angle)) + end + push!(ps, first(ps)) + end + return ps + end + spineplot = lines!( + po.overlay, + spine_points, + color = po.spinecolor, + linewidth = po.spinewidth, + linestyle = po.spinestyle, + visible = po.spinevisible + ) - # The scene area is a rectangle that can include a lot of empty space. With - # this we allow the title to draw in that empty space - mini = min(w, h) - h = top(area) - 0.5 * (h - mini) + notify(po.target_thetalims) - yoffset::Float32 = h + titlegap + translate!.((rticklabelplot, thetaticklabelplot), 0, 0, 9002) + translate!(spineplot, 0, 0, 9001) + translate!.((outer_clip_plot, inner_clip_plot), 0, 0, 9000) + on(po.blockscene, po.gridz) do depth + translate!.((rgridplot, thetagridplot, rminorgridplot, thetaminorgridplot), 0, 0, depth) + end + notify(po.gridz) - return Point2f(x, yoffset) + return rticklabelplot, thetaticklabelplot end @@ -361,10 +862,12 @@ end ### Plotting ################################################################################ +# TODO: consider enabling this +# needs_tight_limits(::Surface) = true -function Makie.plot!( - po::PolarAxis, P::Makie.PlotFunc, - attributes::Makie.Attributes, args...; +function plot!( + po::PolarAxis, P::PlotFunc, + attributes::Attributes, args...; kw_attributes...) allattrs = merge(attributes, Attributes(kw_attributes)) @@ -372,105 +875,91 @@ function Makie.plot!( cycle = get_cycle_for_plottype(allattrs, P) add_cycle_attributes!(allattrs, P, cycle, po.cycler, po.palette) - plot = Makie.plot!(po.scene, P, allattrs, args...) + plot = plot!(po.scene, P, allattrs, args...) - reset_limits!(po) + needs_tight_limits(plot) && tightlimits!(po) + + if is_open_or_any_parent(po.scene) + reset_limits!(po) + end plot end -function Makie.plot!(P::Makie.PlotFunc, po::PolarAxis, args...; kw_attributes...) - attributes = Makie.Attributes(kw_attributes) - Makie.plot!(po, P, attributes, args...) +function plot!(P::PlotFunc, po::PolarAxis, args...; kw_attributes...) + attributes = Attributes(kw_attributes) + plot!(po, P, attributes, args...) end +delete!(ax::PolarAxis, p::AbstractPlot) = delete!(ax.scene, p) + +function update_state_before_display!(ax::PolarAxis) + reset_limits!(ax) + return +end ################################################################################ -### Limits and Camera +### Utilities ################################################################################ -function setup_camera_matrices!(po::PolarAxis) - # Initialization - axis_radius = Observable(0.8) - init = Observable{Float64}(isnothing(po.radius[]) ? 10.0 : po.radius[]) - setfield!(po, :target_radius, init) - on(_ -> reset_limits!(po), po.blockscene, po.radius) - camera(po.overlay).view[] = Mat4f(I) - - e = events(po.scene) - - # scroll to zoom - on(po.blockscene, e.scroll) do scroll - if Makie.is_mouseinside(po.scene) - po.target_radius[] = po.target_radius[] * (1.1 ^ (-scroll[2])) - return Consume(true) - end - return Consume(false) - end - - # Reset button - onany(po.blockscene, e.mousebutton, e.keyboardbutton) do e1, e2 - if ispressed(e, po.reset_button[]) && is_mouseinside(po.scene) && - (e1.action == Mouse.press) && (e2.action == Keyboard.press) - if ispressed(e, Keyboard.left_shift) - autolimits!(po) - else - reset_limits!(po) - end - return Consume(true) - end - return Consume(false) +function normalize_thetalims(thetamin, thetamax) + diff = thetamax - thetamin + if diff < 2pi + # displayed limits may go from -diff .. 0 to 0 .. diff + thetamin_norm = mod(thetamin, -diff..(2pi-diff)) + thetamax_norm = thetamin_norm + clamp(diff, 0, 2pi) + return thetamin_norm, thetamax_norm + else + return thetamin, thetamax end +end - # update view matrix - onany(po.blockscene, axis_radius, po.target_radius) do ar, radius - ratio = ar / radius - camera(po.scene).view[] = Makie.scalematrix(Vec3f(ratio, ratio, 1)) - return +""" + autolimits!(ax::PolarAxis[, unlock_zoom = true]) + +Calling this tells the PolarAxis to derive limits freely from the plotted data, +which allows rmin > 0 and thetalimits spanning less than a full circle. If +`unlock_zoom = true` this also unlocks zooming in r and theta direction and +allows for translations in r direction. +""" +function autolimits!(po::PolarAxis, unlock_zoom = true) + po.rlimits[] = (nothing, nothing) + po.thetalimits[] = (nothing, nothing) + if unlock_zoom + po.fixrmin[] = false + po.rzoomlock[] = false + po.thetazoomlock[] = false end + return +end - max_z = 10_000f0 +function tightlimits!(po::PolarAxis) + po.rautolimitmargin = (0, 0) + po.thetaautolimitmargin = (0, 0) + reset_limits!(po) +end - # update projection matrices - # this just aspect-aware clip space (-1 .. 1, -h/w ... h/w, -max_z ... max_z) - on(po.blockscene, po.scene.px_area) do area - aspect = Float32((/)(widths(area)...)) - w = 1f0 - h = 1f0 / aspect - camera(po.scene).projection[] = Makie.orthographicprojection(-w, w, -h, h, -max_z, max_z) - end - on(po.blockscene, po.overlay.px_area) do area - aspect = Float32((/)(widths(area)...)) - w = 1f0 - h = 1f0 / aspect - camera(po.overlay).projection[] = Makie.orthographicprojection(-w, w, -h, h, -max_z, max_z) - end +""" + rlims!(ax::PolarAxis[, rmin], rmax) - return axis_radius -end +Sets the radial limits of a given `PolarAxis`. +""" +rlims!(po::PolarAxis, r::Union{Nothing, Real}) = rlims!(po, po.rlimits[][1], r) -function reset_limits!(po::PolarAxis) - if isnothing(po.radius[]) - if !isempty(po.scene.plots) - # WTF, why does this include child scenes by default? - lims3d = data_limits(po.scene, p -> !(p in po.scene.plots)) - po.target_radius[] = maximum(lims3d)[1] - end - elseif po.target_radius[] != po.radius[] - po.target_radius[] = po.radius[] - end +function rlims!(po::PolarAxis, rmin::Union{Nothing, Real}, rmax::Union{Nothing, Real}) + po.rlimits[] = (rmin, rmax) return end -function autolimits!(po::PolarAxis) - po.radius[] = nothing - return -end +""" + thetalims!(ax::PolarAxis, thetamin, thetamax) -function rlims!(po::PolarAxis, r::Real) - po.radius[] = r +Sets the angular limits of a given `PolarAxis`. +""" +function thetalims!(po::PolarAxis, thetamin::Union{Nothing, Real}, thetamax::Union{Nothing, Real}) + po.thetalimits[] = (thetamin, thetamax) return end diff --git a/src/makielayout/lineaxis.jl b/src/makielayout/lineaxis.jl index 3be81085132..2e0a41122b8 100644 --- a/src/makielayout/lineaxis.jl +++ b/src/makielayout/lineaxis.jl @@ -689,6 +689,38 @@ function get_ticks(m::MultiplesTicks, any_scale, ::Automatic, vmin, vmax) multiples .* m.multiple, Showoff.showoff(multiples) .* m.suffix end +function get_ticks(m::AngularTicks, any_scale, ::Automatic, vmin, vmax) + dvmin = vmin + dvmax = vmax + delta = dvmax - dvmin + + # get proposed step from + step = delta / max(2, mapreduce(v -> v[1] * delta + v[2], min, m.n_ideal)) + if delta ≥ 0.05 # ≈ 3° + # rad values for (1, 2, 3, 5, 10, 15, 30, 45, 60, 90, 120) degrees + ideal_step = 0.017453292519943295 + for option in (0.03490658503988659, 0.05235987755982989, 0.08726646259971647, 0.17453292519943295, 0.2617993877991494, 0.5235987755982988, 0.7853981633974483, 1.0471975511965976, 1.5707963267948966, 2.0943951023931953) + if (step - option)^2 < (step - ideal_step)^2 + ideal_step = option + end + end + + ϵ = 1e-6 + vmin = ceil(Int, dvmin / ideal_step - ϵ) * ideal_step + vmax = floor(Int, dvmax / ideal_step + ϵ) * ideal_step + multiples = collect(vmin:ideal_step:vmax+ϵ) + else + s = 360/2pi + multiples = Makie.get_tickvalues(LinearTicks(3), s * dvmin, s * dvmax) ./ s + end + + # We need to round this to avoid showoff giving us 179 for 179.99999999999997 + # We also need to be careful that we don't remove significant digits + sigdigits = ceil(Int, log10(1000 * max(abs(vmin), abs(vmax)) / delta)) + + return multiples, Showoff.showoff(round.(multiples .* m.label_factor, sigdigits = sigdigits)) .* m.suffix +end + # identity or unsupported scales function get_minor_tickvalues(i::IntervalsBetween, scale, tickvalues, vmin, vmax) vals = Float64[] diff --git a/src/makielayout/types.jl b/src/makielayout/types.jl index 3a4527d50c0..7f3595833e4 100644 --- a/src/makielayout/types.jl +++ b/src/makielayout/types.jl @@ -82,6 +82,26 @@ struct MultiplesTicks suffix::String end +""" + AngularTicks(label_factor, suffix[, n_ideal::Vector{Vec2f}]) + +Sets up AngularTicks with a predetermined amount of ticks. `label_factor` can be +used to transform the tick labels from radians to degree. `suffix` is added to +the end of the generated label strings. `n_ideal` can be used to affect the ideal +number of ticks. It represents a set of linear function which are combined using +`mapreduce(v -> v[1] * delta + v[2], min, m.n_ideal)` where +`delta = maximum(limits) - minimum(limits)`. +""" +struct AngularTicks + label_factor::Float64 + suffix::String + n_ideal::Vector{Vec2f} + function AngularTicks(label_factor, suffix, n_ideal = [Vec2f(0, 9), Vec2f(3.8, 4)]) + return new(label_factor, suffix, n_ideal) + end +end + + # """ # LogitTicks{T}(linear_ticks::T) @@ -1621,133 +1641,215 @@ end @Block PolarAxis begin scene::Scene overlay::Scene - target_radius::Observable{Float64} + target_rlims::Observable{Tuple{Float64, Float64}} + target_thetalims::Observable{Tuple{Float64, Float64}} + target_theta_0::Observable{Float32} cycler::Cycler palette::Attributes @attributes begin + # Generic + "The height setting of the scene." height = nothing "The width setting of the scene." width = nothing "Controls if the parent layout can adjust to this element's width" - tellwidth = true + tellwidth::Bool = true "Controls if the parent layout can adjust to this element's height" - tellheight = true + tellheight::Bool = true "The horizontal alignment of the scene in its suggested bounding box." halign = :center "The vertical alignment of the scene in its suggested bounding box." valign = :center "The alignment of the scene in its suggested bounding box." alignmode = Inside() + + # Background / clip settings + "The background color of the axis." backgroundcolor = inherit(scene, :backgroundcolor, :white) - "The maximum radius of the PolarAxis. This acts as the limit of the axis." - radius = nothing + "The density at which curved lines are sampled. (grid lines, spine lines, clip)" + sample_density::Int = 120 + "Controls whether to activate the nonlinear clip feature. Note that this should not be used when the background is ultimately transparent." + clip::Bool = true + "Sets the color of the clip polygon. Mainly for debug purposes." + clipcolor = automatic + "Sets a threshold relative to `rmin/rmax` after which radii are distorted to fit more on the screen. No distortion is applied if `radial_distortion_threshold ≥ 1`" + radial_distortion_threshold::Float64 = 1.0 + + # Limits & transformation settings + + "The radial limits of the PolarAxis." + rlimits = (0.0, nothing) + "The angle limits of the PolarAxis. (0.0, 2pi) results a full circle. (nothing, nothing) results in limits picked based on plot limits." + thetalimits = (0.0, 2pi) "The direction of rotation. Can be -1 (clockwise) or 1 (counterclockwise)." - direction = 1 + direction::Int = 1 "The angular offset for (1, 0) in the PolarAxis. This rotates the axis." - theta_0 = 0f0 + theta_0::Float32 = 0f0 + "Controls the argument order of the Polar transform. If `theta_as_x = true` it is (θ, r), otherwise (r, θ)." + theta_as_x::Bool = true + "The relative margins added to the autolimits in r direction." + rautolimitmargin::Tuple{Float64, Float64} = (0.05, 0.05) + "The relative margins added to the autolimits in theta direction." + thetaautolimitmargin::Tuple{Float64, Float64} = (0.05, 0.05) + + # Spine + "The width of the spine." - spinewidth = 2 + spinewidth::Float32 = 2 "The color of the spine." spinecolor = :black "Controls whether the spine is visible." - spinevisible = true + spinevisible::Bool = true "The linestyle of the spine." spinestyle = nothing + + # r ticks + "The specifier for the radial (`r`) ticks, similar to `xticks` for a normal Axis." rticks = LinearTicks(4) "The specifier for the minor `r` ticks." rminorticks = IntervalsBetween(2) - "The color of the `r` grid." - rgridcolor = inherit(scene, (:Axis, :xgridcolor), (:black, 0.5)) - "The linewidth of the `r` grid." - rgridwidth = inherit(scene, (:Axis, :xgridwidth), 1) - "The linestyle of the `r` grid." - rgridstyle = inherit(scene, (:Axis, :xgridstyle), nothing) - "Controls if the `r` grid is visible." - rgridvisible = inherit(scene, (:Axis, :xgridvisible), true) "The formatter for the `r` ticks" rtickformat = Makie.automatic "The fontsize of the `r` tick labels." - rticklabelsize = inherit(scene, (:Axis, :xticklabelsize), 16) + rticklabelsize::Float32 = inherit(scene, (:Axis, :xticklabelsize), 16) "The font of the `r` tick labels." rticklabelfont = inherit(scene, (:Axis, :xticklabelfont), inherit(scene, :font, Makie.defaultfont())) "The color of the `r` tick labels." rticklabelcolor = inherit(scene, (:Axis, :xticklabelcolor), inherit(scene, :textcolor, :black)) "The width of the outline of `r` ticks. Setting this to 0 will remove the outline." - rticklabelstrokewidth = 0.0 + rticklabelstrokewidth::Float32 = 0.0 "The color of the outline of `r` ticks. By default this uses the background color." rticklabelstrokecolor = automatic "Padding of the `r` ticks label." - rticklabelpad = 4f0 + rticklabelpad::Float32 = 4f0 "Controls if the `r` ticks are visible." - rticklabelsvisible = inherit(scene, (:Axis, :xticklabelsvisible), true) + rticklabelsvisible::Bool = inherit(scene, (:Axis, :xticklabelsvisible), true) "The angle in radians along which the `r` ticks are printed." - rtickangle = π/8 + rtickangle = automatic + """ + Sets the rotation of `r` tick labels. + + Options: + - `:radial` rotates labels based on the angle they appear at + - `:horizontal` keeps labels at a horizontal orientation + - `:aligned` rotates labels based on the angle they appear at but keeps them up-right and close to horizontal + - `automatic` uses `:horizontal` when theta limits span >1.9pi and `:aligned` otherwise + - `::Real` sets the label rotation to a specific value + """ + rticklabelrotation = automatic + + # Theta ticks + "The specifier for the angular (`theta`) ticks, similar to `yticks` for a normal Axis." - thetaticks = ((0:45:315) .* pi/180, ["$(x)°" for x in 0:45:315]) + thetaticks = AngularTicks(180/pi, "°") # ((0:45:315) .* pi/180, ["$(x)°" for x in 0:45:315]) "The specifier for the minor `theta` ticks." thetaminorticks = IntervalsBetween(2) - "The color of the `theta` grid." - thetagridcolor = inherit(scene, (:Axis, :ygridcolor), (:black, 0.5)) - "The linewidth of the `theta` grid." - thetagridwidth = inherit(scene, (:Axis, :ygridwidth), 1) - "The linestyle of the `theta` grid." - thetagridstyle = inherit(scene, (:Axis, :ygridstyle), nothing) - "Controls if the `theta` grid is visible." - thetagridvisible = inherit(scene, (:Axis, :ygridvisible), true) "The formatter for the `theta` ticks." thetatickformat = Makie.automatic "The fontsize of the `theta` tick labels." - thetaticklabelsize = inherit(scene, (:Axis, :yticklabelsize), 16) + thetaticklabelsize::Float32 = inherit(scene, (:Axis, :yticklabelsize), 16) "The font of the `theta` tick labels." thetaticklabelfont = inherit(scene, (:Axis, :yticklabelfont), inherit(scene, :font, Makie.defaultfont())) "The color of the `theta` tick labels." thetaticklabelcolor = inherit(scene, (:Axis, :yticklabelcolor), inherit(scene, :textcolor, :black)) "Padding of the `theta` ticks label." - thetaticklabelpad = 4f0 + thetaticklabelpad::Float32 = 4f0 "The width of the outline of `theta` ticks. Setting this to 0 will remove the outline." - thetaticklabelstrokewidth = 0.0 + thetaticklabelstrokewidth::Float32 = 0.0 "The color of the outline of `theta` ticks. By default this uses the background color." thetaticklabelstrokecolor = automatic "Controls if the `theta` ticks are visible." - thetaticklabelsvisible = inherit(scene, (:Axis, :yticklabelsvisible), true) - "The title of the plot" - title = " " - "The gap between the title and the top of the axis" - titlegap = inherit(scene, (:Axis, :titlesize), map(x -> x / 2, inherit(scene, :fontsize, 16))) - "The alignment of the title. Can be any of `:center`, `:left`, or `:right`." - titlealign = :center - "The fontsize of the title." - titlesize = inherit(scene, (:Axis, :titlesize), map(x -> 1.2x, inherit(scene, :fontsize, 16))) - "The font of the title." - titlefont = inherit(scene, (:Axis, :titlefont), inherit(scene, :font, Makie.defaultfont())) - "The color of the title." - titlecolor = inherit(scene, (:Axis, :titlecolor), inherit(scene, :textcolor, :black)) - "Controls if the title is visible." - titlevisible = inherit(scene, (:Axis, :titlevisible), true) + thetaticklabelsvisible::Bool = inherit(scene, (:Axis, :yticklabelsvisible), true) + "Sets whether shown theta ticks get normalized to a -2pi to 2pi range. If not, the limits such as (2pi, 4pi) will be shown as that range." + normalize_theta_ticks::Bool = true + + # r minor and major grid + + "Sets the z value of grid lines. To place the grid above plots set this to a value between 1 and 8999." + gridz::Float32 = -100 + + "The color of the `r` grid." + rgridcolor = inherit(scene, (:Axis, :xgridcolor), (:black, 0.5)) + "The linewidth of the `r` grid." + rgridwidth::Float32 = inherit(scene, (:Axis, :xgridwidth), 1) + "The linestyle of the `r` grid." + rgridstyle = inherit(scene, (:Axis, :xgridstyle), nothing) + "Controls if the `r` grid is visible." + rgridvisible::Bool = inherit(scene, (:Axis, :xgridvisible), true) + "The color of the `r` minor grid." rminorgridcolor = inherit(scene, (:Axis, :xminorgridcolor), (:black, 0.2)) "The linewidth of the `r` minor grid." - rminorgridwidth = inherit(scene, (:Axis, :xminorgridwidth), 1) + rminorgridwidth::Float32 = inherit(scene, (:Axis, :xminorgridwidth), 1) "The linestyle of the `r` minor grid." rminorgridstyle = inherit(scene, (:Axis, :xminorgridstyle), nothing) "Controls if the `r` minor grid is visible." - rminorgridvisible = inherit(scene, (:Axis, :xminorgridvisible), false) + rminorgridvisible::Bool = inherit(scene, (:Axis, :xminorgridvisible), false) + + # Theta minor and major grid + + "The color of the `theta` grid." + thetagridcolor = inherit(scene, (:Axis, :ygridcolor), (:black, 0.5)) + "The linewidth of the `theta` grid." + thetagridwidth::Float32 = inherit(scene, (:Axis, :ygridwidth), 1) + "The linestyle of the `theta` grid." + thetagridstyle = inherit(scene, (:Axis, :ygridstyle), nothing) + "Controls if the `theta` grid is visible." + thetagridvisible::Bool = inherit(scene, (:Axis, :ygridvisible), true) + + "The color of the `theta` minor grid." thetaminorgridcolor = inherit(scene, (:Axis, :yminorgridcolor), (:black, 0.2)) "The linewidth of the `theta` minor grid." - thetaminorgridwidth = inherit(scene, (:Axis, :yminorgridwidth), 1) + thetaminorgridwidth::Float32 = inherit(scene, (:Axis, :yminorgridwidth), 1) "The linestyle of the `theta` minor grid." thetaminorgridstyle = inherit(scene, (:Axis, :yminorgridstyle), nothing) "Controls if the `theta` minor grid is visible." - thetaminorgridvisible = inherit(scene, (:Axis, :yminorgridvisible), false) - "The density at which grid lines are sampled." - sample_density = 100 - "Controls whether to activate the nonlinear clip feature. Note that this should not be used when the background is ultimately transparent." - clip = true + thetaminorgridvisible::Bool = inherit(scene, (:Axis, :yminorgridvisible), false) + + # Title + + "The title of the plot" + title = "" + "The gap between the title and the top of the axis" + titlegap::Float32 = inherit(scene, (:Axis, :titlesize), map(x -> x / 2, inherit(scene, :fontsize, 16))) + "The alignment of the title. Can be any of `:center`, `:left`, or `:right`." + titlealign = :center + "The fontsize of the title." + titlesize::Float32 = inherit(scene, (:Axis, :titlesize), map(x -> 1.2x, inherit(scene, :fontsize, 16))) + "The font of the title." + titlefont = inherit(scene, (:Axis, :titlefont), inherit(scene, :font, Makie.defaultfont())) + "The color of the title." + titlecolor = inherit(scene, (:Axis, :titlecolor), inherit(scene, :textcolor, :black)) + "Controls if the title is visible." + titlevisible::Bool = inherit(scene, (:Axis, :titlevisible), true) + + # Interactive Controls + + "Sets the speed of scroll based zooming. Setting this to 0 effectively disables zooming." + zoomspeed::Float32 = 0.1 + "Sets the key used to restrict zooming to the r-direction. Can be set to `true` to always restrict zooming or `false` to disable the interaction." + rzoomkey = Keyboard.r + "Sets the key used to restrict zooming to the theta-direction. Can be set to `true` to always restrict zooming or `false` to disable the interaction." + thetazoomkey = Keyboard.t + "Controls whether rmin remains fixed during zooming and translation. (The latter will be turned off by setting this to true.)" + fixrmin::Bool = true + "Controls whether adjusting the rlimits through interactive zooming is blocked." + rzoomlock::Bool = false + "Controls whether adjusting the thetalimits through interactive zooming is blocked." + thetazoomlock::Bool = true + "Sets the mouse button for translating the plot in r-direction." + r_translation_button = Mouse.right + "Sets the mouse button for translating the plot in theta-direction. Note that this can be the same as `radial_translation_button`." + theta_translation_button = Mouse.right + "Sets the button for rotating the PolarAxis as a whole. This replaces theta translation when triggered and must include a mouse button." + axis_rotation_button = Keyboard.left_control & Mouse.right "Sets the button or button combination for resetting the axis view. (This should be compatible with `ispressed`.)" reset_button = Keyboard.left_control & Mouse.left + "Sets whether the axis orientation (changed with the axis_rotation_button) gets reset when resetting the axis. If set to false only the limits will reset." + reset_axis_orientation::Bool = false end end diff --git a/test/PolarAxis.jl b/test/PolarAxis.jl new file mode 100644 index 00000000000..1866f439743 --- /dev/null +++ b/test/PolarAxis.jl @@ -0,0 +1,160 @@ +@testset "PolarAxis" begin + @testset "rtick rotations" begin + f = Figure() + angles = [ + 7pi/4+0.01, 0, pi/4-0.01, + pi/4+0.01, pi/2, 3pi/4-0.01, + 3pi/4+0.01, pi, 5pi/4-0.01, + 5pi/4+0.01, 3pi/2, 7pi/4-0.01, + ] + po = PolarAxis( + f[1, 1], thetalimits = (0, pi/4), rticklabelrotation = Makie.automatic, + rticklabelpad = 10f0 + ) + rticklabelplot = po.overlay.plots[5].plots[1] + + # Mostly for verfication that we got the right plot + @test po.overlay.plots[5][1][] == [("0.0", Point2f(0.0, 0.0)), ("2.5", Point2f(0.25, 0.0)), ("5.0", Point2f(0.5, 0.0)), ("7.5", Point2f(0.75, 0.0)), ("10.0", Point2f(1.0, 0.0))] + + # automatic + for i in 1:4 + align = (Vec2f(0.5, 1.0), Vec2f(0.0, 0.5), Vec2f(0.5, 0.0), Vec2f(1.0, 0.5))[i] + for j in 1:3 + po.theta_0[] = angles[j + 3(i-1)] + s, c = sincos(angles[j + 3(i-1)] - pi/2) + @test rticklabelplot.plots[1].offset[] ≈ 10f0 * Vec2f(c, s) + @test rticklabelplot.align[] ≈ align + @test isapprox(mod(rticklabelplot.rotation[], -pi..pi), (-pi/4+0.01, 0, pi/4-0.01)[j], atol = 1e-3) + end + end + + # value + v = 2pi * rand() + po.rticklabelrotation[] = v + s, c = sincos(po.theta_0[] - pi/2) + @test rticklabelplot.plots[1].offset[] ≈ 10f0 * Vec2f(c, s) + scale = 1 / max(abs(s), abs(c)) + @test rticklabelplot.align[] ≈ Point2f(0.5 - 0.5scale * c, 0.5 - 0.5scale * s) + @test rticklabelplot.rotation[] ≈ v + + # horizontal + po.rticklabelrotation[] = :horizontal + @test rticklabelplot.plots[1].offset[] ≈ 10f0 * Vec2f(c, s) + @test rticklabelplot.align[] ≈ Point2f(0.5 - 0.5scale * c, 0.5 - 0.5scale * s) + @test rticklabelplot.rotation[] ≈ 0f0 + + # radial + po.rticklabelrotation[] = :radial + @test rticklabelplot.plots[1].offset[] ≈ 10f0 * Vec2f(c, s) + @test rticklabelplot.align[] ≈ Vec2f(0, 0.5) + @test rticklabelplot.rotation[] ≈ po.theta_0[] - pi/2 + + # aligned + po.rticklabelrotation[] = :aligned + @test rticklabelplot.plots[1].offset[] ≈ 10f0 * Vec2f(c, s) + @test rticklabelplot.align[] ≈ Vec2f(1, 0.5) + @test rticklabelplot.rotation[] ≈ po.theta_0[] - 3pi/2 + end + + + @testset "Limits" begin + # Should not error (0 width limits) + fig = Figure() + ax = PolarAxis(fig[1, 1]) + p = scatter!(ax, Point2f(0)) + + # verify defaults + @test ax.rautolimitmargin[] == (0.05, 0.05) + @test ax.thetaautolimitmargin[] == (0.05, 0.05) + + # default should have mostly set default limits + @test ax.rlimits[] == (0.0, nothing) + @test ax.thetalimits[] == (0.0, 2pi) + @test ax.target_rlims[] == (0.0, 10.0) + @test ax.target_thetalims[] == (0.0, 2pi) + + # but we want to test automatic limits here + autolimits!(ax) + reset_limits!(ax) # needed because window isn't open + @test ax.rlimits[] == (nothing, nothing) + @test ax.thetalimits[] == (nothing, nothing) + @test ax.target_rlims[] == (0.0, 10.0) + @test ax.target_thetalims[] == (0.0, 2pi) + + # derived r, default theta + scatter!(ax, Point2f(0, 1)) + reset_limits!(ax) + @test ax.target_rlims[] == (0.0, 1.05) + @test ax.target_thetalims[] == (0.0, 2pi) + + # back to full default + delete!(ax, p) + reset_limits!(ax) + @test ax.target_rlims[] == (0.0, 10.0) + @test ax.target_thetalims[] == (0.0, 2pi) + + # default r, derived theta + scatter!(ax, Point2f(0.5pi, 1)) + reset_limits!(ax) + @test ax.target_rlims[] == (0.0, 10.0) + @test all(isapprox.(ax.target_thetalims[], (-0.025pi, 0.525pi), rtol=1e-6)) + + # derive both + scatter!(ax, Point2f(pi, 2)) + reset_limits!(ax) + @test all(isapprox.(ax.target_rlims[], (0.95, 2.05), rtol=1e-6)) + @test all(isapprox.(ax.target_thetalims[], (-0.05pi, 1.05pi), rtol=1e-6)) + + # set limits + rlims!(ax, 0.0, 3.0) + reset_limits!(ax) + @test ax.rlimits[] == (0.0, 3.0) + @test ax.target_rlims[] == (0.0, 3.0) + @test all(isapprox.(ax.target_thetalims[], (-0.05pi, 1.05pi), rtol=1e-6)) + + thetalims!(ax, 0.0, 2pi) + reset_limits!(ax) + @test ax.rlimits[] == (0.0, 3.0) + @test ax.target_rlims[] == (0.0, 3.0) + @test ax.thetalimits[] == (0.0, 2pi) + @test ax.target_thetalims[] == (0.0, 2pi) + + # test tightlimits + fig = Figure() + ax = PolarAxis(fig[1, 1]) + surface!(ax, 0.5pi..pi, 2..5, rand(10, 10)) + tightlimits!(ax) + + @test ax.rautolimitmargin[] == (0.0, 0.0) + @test ax.thetaautolimitmargin[] == (0.0, 0.0) + + # with default limits + reset_limits!(ax) + @test ax.rlimits[] == (0.0, nothing) + @test ax.thetalimits[] == (0.0, 2pi) + @test ax.target_rlims[] == (0.0, 5.0) + @test ax.target_thetalims[] == (0.0, 2pi) + + # with fully automatic limits + autolimits!(ax) + reset_limits!(ax) + @test ax.rlimits[] == (nothing, nothing) + @test ax.thetalimits[] == (nothing, nothing) + @test ax.target_rlims[] == (2.0, 5.0) + @test all(isapprox.(ax.target_thetalims[], (0.5pi, 1.0pi), rtol=1e-6)) + end + + @testset "Radial Distortion" begin + fig = Figure() + ax = PolarAxis(fig[1, 1], radial_distortion_threshold = 0.2, rlimits = (0, 10)) + tf = ax.scene.transformation.transform_func + @test /(ax.target_rlims[]...) == 0.0 + @test /((ax.target_rlims[] .- tf[].r0)...) == 0.0 + rlims!(ax, 1, 10) + @test /(ax.target_rlims[]...) == 0.1 + @test /((ax.target_rlims[] .- tf[].r0)...) == 0.1 + rlims!(ax, 5, 10) + @test /(ax.target_rlims[]...) == 0.5 + @test /((ax.target_rlims[] .- tf[].r0)...) ≈ 0.2 + end +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index a2ecfa4e6b3..3ade28d3d1b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -33,5 +33,6 @@ using Makie: volume include("text.jl") include("boundingboxes.jl") include("ray_casting.jl") + include("PolarAxis.jl") include("barplot_labels.jl") end diff --git a/test/transformations.jl b/test/transformations.jl index ac66cf247af..6033d5f3381 100644 --- a/test/transformations.jl +++ b/test/transformations.jl @@ -88,9 +88,11 @@ end end @testset "Polar Transform" begin - tf = Makie.Polar() + tf = Makie.Polar(false) + @test tf.theta_as_x == false @test tf.theta_0 == 0.0 @test tf.direction == 1 + @test tf.r0 == 0.0 input = Point2f.(1:6, [0, pi/3, pi/2, pi, 2pi, 3pi]) output = [r * Point2f(cos(phi), sin(phi)) for (r, phi) in input] @@ -98,15 +100,27 @@ end @test apply_transform(tf, input) ≈ output @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv - tf = Makie.Polar(pi/2) + tf = Makie.Polar(false, pi/2) output = [r * Point2f(cos(phi+pi/2), sin(phi+pi/2)) for (r, phi) in input] @test apply_transform(tf, input) ≈ output @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv - tf = Makie.Polar(pi/2, -1) + tf = Makie.Polar(false, pi/2, -1) output = [r * Point2f(cos(-phi-pi/2), sin(-phi-pi/2)) for (r, phi) in input] @test apply_transform(tf, input) ≈ output @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv + + tf = Makie.Polar(false, pi/2, -1, 0.5) + output = [(r - 0.5) * Point2f(cos(-phi-pi/2), sin(-phi-pi/2)) for (r, phi) in input] + @test apply_transform(tf, input) ≈ output + @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv + + tf = Makie.Polar(true) + input = Point2f.([0, pi/3, pi/2, pi, 2pi, 3pi], 1:6) + output = [r * Point2f(cos(phi), sin(phi)) for (phi, r) in input] + inv = Point2f.(mod.([0, pi/3, pi/2, pi, 2pi, 3pi], (0..2pi,)), 1:6) + @test apply_transform(tf, input) ≈ output + @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv end @testset "Coordinate Systems" begin @@ -147,8 +161,8 @@ end p3 = Point(2.0, 5.0, 4.0) spaces_and_desired_transforms = Dict( - :data => (x,y) -> y, # uses changes - :clip => (x,y) -> x, # no change + :data => (x,y) -> y, # uses changes + :clip => (x,y) -> x, # no change :relative => (x,y) -> x, # no change :pixel => (x,y) -> x, # no transformation ) @@ -163,5 +177,5 @@ end @test apply_transform(t2, p3, space) == desired_transform(p3, Point3f(sqrt(2.0), log(5.0), 4.0)) @test apply_transform(t3, p3, space) == desired_transform(p3, Point3f(sqrt(2.0), log(5.0), log10(4.0))) - end + end end