+ {{if searchpage}} + {{else}} + + {{end}} +
+ + +
+ {{ contenttable }} +
+
") - if active - print(io, contenttable()) - end - printlist(io, naventry.children, this_level) print(io, "\n") end @@ -410,16 +443,24 @@ end end -function contenttable() - isempty(Franklin.PAGE_HEADERS) && return "" +function hfun_contenttable() + + headers = collect(Franklin.PAGE_HEADERS) + + # remove first heading 1 + if !isempty(headers) && headers[1][2][3] == 1 + headers = headers[2:end] + end + + isempty(headers) && return "" return sprint() do io println(io, """
    """) - order_stack = [first(Franklin.PAGE_HEADERS)[2][3]] + order_stack = [first(headers)[2][3]] - for (key, val) in Franklin.PAGE_HEADERS + for (key, val) in headers order = val[3] n_steps_up = count(>=(order), order_stack) @@ -463,7 +504,7 @@ function lx_attrdocs(lxc, _) println(io) println(io, "Defaults to `$default_str`") println(io) - + if docs === nothing println(io, "No docstring defined for attribute `$attrkey`.") else @@ -485,10 +526,9 @@ function lx_attrdocs(lxc, _) println(io, "\\end{examplefigure}") println(io) end - + println(io) end return String(take!(io)) end - diff --git a/metrics/ttfp/benchmark-ttfp.jl b/metrics/ttfp/benchmark-ttfp.jl index 9c422f774aa..274b1c3e38d 100644 --- a/metrics/ttfp/benchmark-ttfp.jl +++ b/metrics/ttfp/benchmark-ttfp.jl @@ -8,24 +8,17 @@ macro ctime(x) end t_using = @ctime @eval using $Package -function get_colorbuffer(fig) - # We need to handle old versions of Makie - if isdefined(Makie, :CURRENT_BACKEND) # new version after display refactor - return Makie.colorbuffer(fig) # easy :) - else - Makie.inline!(false) - screen = display(fig; visible=false) - return Makie.colorbuffer(screen) - end -end - if Package === :WGLMakie import Electron - WGLMakie.JSServe.use_electron_display() + # Backwards compatibility for master + Bonito = isdefined(WGLMakie, :Bonito) ? WGLMakie.Bonito : WGLMakie.JSServe + Bonito.use_electron_display() end +set_theme!(size=(800, 600)) + create_time = @ctime fig = scatter(1:4; color=1:4, colormap=:turbo, markersize=20, visible=true) -display_time = @ctime get_colorbuffer(fig) +display_time = @ctime colorbuffer(fig; px_per_unit=1) using BenchmarkTools using BenchmarkTools.JSON @@ -39,7 +32,7 @@ old = isfile(result) ? JSON.parse(read(result, String)) : [[], [], [], [], []] push!.(old[1:3], [t_using, create_time, display_time]) b1 = @benchmark fig = scatter(1:4; color=1:4, colormap=:turbo, markersize=20, visible=true) -b2 = @benchmark get_colorbuffer(fig) setup=(fig=scatter(1:4)) +b2 = @benchmark colorbuffer(fig; px_per_unit=1) using Statistics diff --git a/metrics/ttfp/run-benchmark.jl b/metrics/ttfp/run-benchmark.jl index 46c11b19319..f1b76472ac3 100644 --- a/metrics/ttfp/run-benchmark.jl +++ b/metrics/ttfp/run-benchmark.jl @@ -34,7 +34,7 @@ create_time = @ctime fig = scatter(1:4; color=1:4, colormap=:turbo, markersize=2 display_time = @ctime Makie.colorbuffer(display(fig)) # Runtime create_time = @benchmark fig = scatter(1:4; color=1:4, colormap=:turbo, markersize=20, visible=true) -display_time = @benchmark Makie.colorbuffer(display(fig)) +display_time = @benchmark Makie.colorbuffer(fig) ``` | | using | create | display | create | display | @@ -87,27 +87,28 @@ function analyze(pr, master) std_p = (std(pr) + std(master)) / 2 m_pr = mean(pr) m_m = mean(master) - mean_diff = mean(m_pr) - mean(m_m) - percent = (1 - (m_m / m_pr)) * 100 + mean_diff = m_pr - m_m + speedup = m_m / m_pr p = pvalue(tt) mean_diff_str = string(round(mean_diff; digits=2), unit) - + percent_change = (speedup - 1) * 100 result = if p < 0.05 if abs(d) > 0.2 - indicator = abs(percent) < 5 ? ["faster ✓", "slower X"] : ["**faster**✅", "**slower**❌"] + indicator = abs(percent_change) < 5 ? ["faster ✓", "slower X"] : ["**faster**✅", "**slower**❌"] indicator[d < 0 ? 1 : 2] else "*invariant*" end else - if abs(percent) < 5 + if abs(percent_change) < 5 "*invariant*" else "*noisy*🤷‍♀️" end end - return @sprintf("%s%.2f%s, %s %s (%.2fd, %.2fp, %.2fstd)", percent > 0 ? "+" : "-", abs(percent), "%", mean_diff_str, result, d, p, std_p) + return @sprintf("%.2fx %s, %s (%.2fd, %.2fp, %.2fstd)", speedup, result, mean_diff_str, d, p, + std_p) end function summarize_stats(timings) @@ -153,6 +154,9 @@ function update_comment(old_comment, package_name, (pr_bench, master_bench, eval for (i, value) in enumerate(evaluation) rows[idx + 2][i + 1] = [value] end + open("benchmark.md", "w") do io + return show(io, md) + end return sprint(show, md) end @@ -171,9 +175,12 @@ function make_or_edit_comment(ctx, pr, package_name, benchmarks) end end + +using Random + function run_benchmarks(projects; n=n_samples) benchmark_file = joinpath(@__DIR__, "benchmark-ttfp.jl") - for project in repeat(projects; outer=n) + for project in shuffle!(repeat(projects; outer=n)) run(`$(Base.julia_cmd()) --startup-file=no --project=$(project) $benchmark_file $Package`) project_name = basename(project) end @@ -207,7 +214,7 @@ ENV["JULIA_PKG_PRECOMPILE_AUTO"] = 0 project1 = make_project_folder("current-pr") Pkg.activate(project1) if Package == "WGLMakie" - Pkg.add([(; name="Electron"), (; name="JSServe", rev="master")]) + Pkg.add([(; name="Electron")]) end pkgs = NamedTuple[(; path="./MakieCore"), (; path="."), (; path="./$Package")] # cd("dev/Makie") diff --git a/precompile/shared-precompile.jl b/precompile/shared-precompile.jl index 923eff489a3..efa7a3999cd 100644 --- a/precompile/shared-precompile.jl +++ b/precompile/shared-precompile.jl @@ -1,6 +1,5 @@ # File to run to snoop/trace all functions to compile using GeometryBasics - @compile poly(Recti(0, 0, 200, 200), strokewidth=20, strokecolor=:red, color=(:black, 0.4)) @compile scatter(0..1, rand(10), markersize=rand(10) .* 20) @@ -55,7 +54,7 @@ end @compile begin res = 200 - s = Scene(camera=campixel!, resolution=(res, res)) + s = Scene(camera=campixel!, size=(res, res)) half = res / 2 linewidth = 10 xstart = half - (half/2) diff --git a/relocatability.jl b/relocatability.jl new file mode 100644 index 00000000000..835aec2fc3a --- /dev/null +++ b/relocatability.jl @@ -0,0 +1,50 @@ + +module_src = """ +module MakieApp + +using GLMakie + +function julia_main()::Cint + screen = display(scatter(1:4)) + # wait(screen) commented out to test if this blocks anything, but didn't change anything + return 0 # if things finished successfully +end + +end # module MakieApp +""" + +using Pkg, Test + +makie_dir = pwd() +tmpdir = mktempdir() +# create a temporary project +cd(tmpdir) +Pkg.generate("MakieApp") +Pkg.activate("MakieApp") + +paths = [makie_dir, joinpath(makie_dir, "MakieCore"), joinpath(makie_dir, "GLMakie")] + +Pkg.develop(map(x-> (;path=x), paths)) + +open("MakieApp/src/MakieApp.jl", "w") do io + print(io, module_src) +end + +Pkg.activate(".") +Pkg.add("PackageCompiler") + +using PackageCompiler + +create_app(joinpath(pwd(), "MakieApp"), "executable"; force=true, incremental=true, include_transitive_dependencies=false) +exe = joinpath(pwd(), "executable", "bin", "MakieApp") +@test success(`$(exe)`) +julia_pkg_dir = joinpath(Base.DEPOT_PATH[1], "packages") +@test isdir(julia_pkg_dir) +mvd_julia_pkg_dir = julia_pkg_dir * ".old" +# Move package dir so that we can test relocatability (hardcoded paths to package dir being invalid now) +try + mv(julia_pkg_dir, mvd_julia_pkg_dir) + @test success(`$(exe)`) +catch e + mv(mvd_julia_pkg_dir, julia_pkg_dir) +end diff --git a/src/Makie.jl b/src/Makie.jl index cb3e0f56d59..3d162267b1a 100644 --- a/src/Makie.jl +++ b/src/Makie.jl @@ -12,10 +12,16 @@ using .ContoursHygiene const Contours = ContoursHygiene.Contour using Base64 +# Import FilePaths for invalidations +# When loading Electron for WGLMakie, which depends on FilePaths +# It invalidates half of Makie. Simplest fix is to load it early on in Makie +# So that the bulk of Makie gets compiled after FilePaths invalidadet Base code +# +import FilePaths using LaTeXStrings using MathTeXEngine using Random -using FFMPEG # get FFMPEG on any system! +using FFMPEG_jll # get FFMPEG on any system! using Observables using GeometryBasics using PlotUtils @@ -23,21 +29,24 @@ using ColorBrewer using ColorTypes using Colors using ColorSchemes +using CRC32c using Packing using SignedDistanceFields using Markdown using DocStringExtensions # documentation +using Scratch using StructArrays # Text related packages using FreeType using FreeTypeAbstraction -using UnicodeFun using LinearAlgebra using Statistics using MakieCore using OffsetArrays using Downloads +using ShaderAbstractions +import UnicodeFun import RelocatableFolders import StatsBase import Distributions @@ -49,8 +58,7 @@ import ImageIO import FileIO import SparseArrays import TriplotBase -import DelaunayTriangulation as DelTri -import Setfield +import DelaunayTriangulation as DelTri import REPL import MacroTools @@ -70,21 +78,24 @@ using Base.Iterators: repeated, drop import Base: getindex, setindex!, push!, append!, parent, get, get!, delete!, haskey using Observables: listeners, to_value, notify -using MakieCore: SceneLike, MakieScreen, ScenePlot, AbstractScene, AbstractPlot, Transformable, Attributes, Combined, Theme, Plot +using MakieCore: SceneLike, MakieScreen, ScenePlot, AbstractScene, AbstractPlot, Transformable, Attributes, Plot, Theme, Plot using MakieCore: Arrows, Heatmap, Image, Lines, LineSegments, Mesh, MeshScatter, Poly, Scatter, Surface, Text, Volume, Wireframe -using MakieCore: ConversionTrait, NoConversion, PointBased, SurfaceLike, ContinuousSurface, DiscreteSurface, VolumeLike +using MakieCore: ConversionTrait, NoConversion, PointBased, GridBased, VertexGrid, CellGrid, ImageLike, VolumeLike using MakieCore: Key, @key_str, Automatic, automatic, @recipe using MakieCore: Pixel, px, Unit, Billboard +using MakieCore: NoShading, FastShading, MultiLightShading using MakieCore: not_implemented_for import MakieCore: plot, plot!, theme, plotfunc, plottype, merge_attributes!, calculated_attributes!, -get_attribute, plotsym, plotkey, attributes, used_attributes + get_attribute, plotsym, plotkey, attributes, used_attributes +import MakieCore: create_axis_like, create_axis_like!, figurelike_return, figurelike_return! import MakieCore: arrows, heatmap, image, lines, linesegments, mesh, meshscatter, poly, scatter, surface, text, volume import MakieCore: arrows!, heatmap!, image!, lines!, linesegments!, mesh!, meshscatter!, poly!, scatter!, surface!, text!, volume! import MakieCore: convert_arguments, convert_attribute, default_theme, conversion_trait export @L_str, @colorant_str -export ConversionTrait, NoConversion, PointBased, SurfaceLike, ContinuousSurface, DiscreteSurface, VolumeLike +export ConversionTrait, NoConversion, PointBased, GridBased, VertexGrid, CellGrid, ImageLike, VolumeLike export Pixel, px, Unit, plotkey, attributes, used_attributes +export Linestyle const RealVector{T} = AbstractVector{T} where T <: Number const RGBAf = RGBA{Float32} @@ -104,6 +115,7 @@ include("interaction/liftmacro.jl") include("colorsampler.jl") include("patterns.jl") include("utilities/utilities.jl") # need Makie.AbstractPattern +include("lighting.jl") # Basic scene/plot/recipe interfaces + types include("scenes.jl") @@ -117,6 +129,7 @@ include("themes/theme_black.jl") include("themes/theme_minimal.jl") include("themes/theme_light.jl") include("themes/theme_dark.jl") +include("themes/theme_latexfonts.jl") # camera types + functions include("camera/projection_math.jl") @@ -138,6 +151,7 @@ include("basic_recipes/buffers.jl") include("basic_recipes/bracket.jl") include("basic_recipes/contours.jl") include("basic_recipes/contourf.jl") +include("basic_recipes/datashader.jl") include("basic_recipes/error_and_rangebars.jl") include("basic_recipes/hvlines.jl") include("basic_recipes/hvspan.jl") @@ -150,7 +164,9 @@ include("basic_recipes/stem.jl") include("basic_recipes/streamplot.jl") include("basic_recipes/timeseries.jl") include("basic_recipes/tricontourf.jl") +include("basic_recipes/triplot.jl") include("basic_recipes/volumeslices.jl") +include("basic_recipes/voronoiplot.jl") include("basic_recipes/waterfall.jl") include("basic_recipes/wireframe.jl") include("basic_recipes/tooltip.jl") @@ -160,6 +176,10 @@ include("layouting/transformation.jl") include("layouting/data_limits.jl") include("layouting/layouting.jl") include("layouting/boundingbox.jl") + +# Declaritive SpecApi +include("specapi.jl") + # more default recipes # statistical recipes include("stats/conversions.jl") @@ -194,7 +214,7 @@ export help, help_attributes, help_arguments # Abstract/Concrete scene + plot types export AbstractScene, SceneLike, Scene, MakieScreen -export AbstractPlot, Combined, Atomic, OldAxis +export AbstractPlot, Plot, Atomic, OldAxis # Theming, working with Plots export Attributes, Theme, attributes, default_theme, theme, set_theme!, with_theme, update_theme! @@ -206,6 +226,7 @@ export theme_black export theme_minimal export theme_light export theme_dark +export theme_latexfonts export xticklabels, yticklabels, zticklabels export xtickrange, ytickrange, ztickrange @@ -214,12 +235,11 @@ export xtickrotation, ytickrotation, ztickrotation export xtickrotation!, ytickrotation!, ztickrotation! # Observable/Signal related -export Observable, Observable, lift, map_once, to_value, on, onany, @lift, off, connect! +export Observable, Observable, lift, to_value, on, onany, @lift, off, connect! # utilities and macros export @recipe, @extract, @extractvalue, @key_str, @get_attribute export broadcast_foreach, to_vector, replace_automatic! - # conversion infrastructure export @key_str, convert_attribute, convert_arguments export to_color, to_colormap, to_rotation, to_font, to_align, to_fontsize, categorical_colors, resample_cmap @@ -237,11 +257,11 @@ export SceneSpace, PixelSpace, Pixel export AbstractCamera, EmptyCamera, Camera, Camera2D, Camera3D, cam2d!, cam2d export campixel!, campixel, cam3d!, cam3d_cad!, old_cam3d!, old_cam3d_cad!, cam_relative! export update_cam!, rotate_cam!, translate_cam!, zoom! -export pixelarea, plots, cameracontrols, cameracontrols!, camera, events +export viewport, plots, cameracontrols, cameracontrols!, camera, events export to_world # picking + interactive use cases + events -export mouseover, onpick, pick, Events, Keyboard, Mouse, mouse_selection, is_mouseinside +export mouseover, onpick, pick, Events, Keyboard, Mouse, is_mouseinside export ispressed, Exclusively export connect_screen export window_area, window_open, mouse_buttons, mouse_position, mouseposition_px, @@ -253,6 +273,7 @@ export Consume # Raymarching algorithms export RaymarchAlgorithm, IsoValue, Absorption, MaximumIntensityProjection, AbsorptionRGBA, IndexedAbsorptionRGBA export Billboard +export NoShading, FastShading, MultiLightShading # Reexports of # Color/Vector types convenient for 3d/2d graphics @@ -272,15 +293,19 @@ export PlotSpec export plot!, plot export abline! # until deprecation removal - export Stepper, replay_events, record_events, RecordEvents, record, VideoStream export VideoStream, recordframe!, record, Record -export save +export save, colorbuffer # colormap stuff from PlotUtils, and showgradients export cgrad, available_gradients, showgradients +# other "available" functions +export available_plotting_methods, available_marker_symbols + + export Pattern +export ReversibleScale export assetpath # default icon for Makie @@ -295,6 +320,9 @@ function logo() FileIO.load(assetpath("logo.png")) end +# populated by __init__() +makie_cache_dir = "" + function __init__() # Make GridLayoutBase default row and colgaps themeable when using Makie # This mutates module-level state so it could mess up other libraries using @@ -312,6 +340,8 @@ function __init__() @warn "The global configuration file is no longer supported." * "Please include the file manually with `include(\"$cfg_path\")` before plotting." end + + global makie_cache_dir = @get_scratch!("makie") end include("figures.jl") @@ -329,7 +359,7 @@ export Arrows , Heatmap , Image , Lines , LineSegments , Mesh , MeshScatte export arrows , heatmap , image , lines , linesegments , mesh , meshscatter , poly , scatter , surface , text , volume , wireframe export arrows! , heatmap! , image! , lines! , linesegments! , mesh! , meshscatter! , poly! , scatter! , surface! , text! , volume! , wireframe! -export PointLight, EnvironmentLight, AmbientLight, SSAO +export AmbientLight, PointLight, DirectionalLight, SpotLight, EnvironmentLight, RectLight, SSAO include("precompiles.jl") diff --git a/src/basic_recipes/ablines.jl b/src/basic_recipes/ablines.jl index 6e3786d82c1..3c2268b241e 100644 --- a/src/basic_recipes/ablines.jl +++ b/src/basic_recipes/ablines.jl @@ -26,7 +26,6 @@ function Makie.plot!(p::ABLines) points = Observable(Point2f[]) onany(p, limits, p[1], p[2]) do lims, intercept, slope - inv = inverse_transform(transf) empty!(points[]) f(x) = x * b + a broadcast_foreach(intercept, slope) do intercept, slope diff --git a/src/basic_recipes/arrows.jl b/src/basic_recipes/arrows.jl index b3f35002e0e..c9a04553ea6 100644 --- a/src/basic_recipes/arrows.jl +++ b/src/basic_recipes/arrows.jl @@ -3,6 +3,7 @@ arrows(x, y, u, v) arrows(x::AbstractVector, y::AbstractVector, u::AbstractMatrix, v::AbstractMatrix) arrows(x, y, z, u, v, w) + arrows(x, y, [z], f::Function) Plots arrows at the specified points with the specified components. `u` and `v` are interpreted as vector components (`u` being the x @@ -18,6 +19,10 @@ grid. `arrows` can also work in three dimensions. +If a `Function` is provided in place of `u, v, [w]`, then it must accept +a `Point` as input, and return an appropriately dimensioned `Point`, `Vec`, +or other array-like output. + ## Attributes $(ATTRIBUTES) """ @@ -101,7 +106,6 @@ function _circle(origin, r, normal, N) GeometryBasics.Mesh(meta(coords; normals=normals), faces) end - convert_arguments(::Type{<: Arrows}, x, y, u, v) = (Point2f.(x, y), Vec2f.(u, v)) function convert_arguments(::Type{<: Arrows}, x::AbstractVector, y::AbstractVector, u::AbstractMatrix, v::AbstractMatrix) (vec(Point2f.(x, y')), vec(Vec2f.(u, v))) @@ -110,11 +114,11 @@ convert_arguments(::Type{<: Arrows}, x, y, z, u, v, w) = (Point3f.(x, y, z), Vec function plot!(arrowplot::Arrows{<: Tuple{AbstractVector{<: Point{N}}, V}}) where {N, V} @extract arrowplot ( - points, directions, colormap, normalize, align, + points, directions, colormap, colorscale, normalize, align, arrowtail, color, linecolor, linestyle, linewidth, lengthscale, arrowhead, arrowsize, arrowcolor, quality, # passthrough - diffuse, specular, shininess, + diffuse, specular, shininess, shading, fxaa, ssao, transparency, visible, inspectable ) @@ -142,7 +146,7 @@ function plot!(arrowplot::Arrows{<: Tuple{AbstractVector{<: Point{N}}, V}}) wher # for 2D arrows, compute the correct marker rotation given the projection / scene size # for the screen-space marker if is_pixel_space(arrowplot.markerspace[]) - rotations = lift(arrowplot, scene.camera.projectionview, scene.px_area, headstart) do pv, pxa, hs + rotations = lift(arrowplot, scene.camera.projectionview, scene.viewport, headstart) do pv, pxa, hs angles = map(hs) do (start, stop) pstart = project(scene, start) pstop = project(scene, stop) @@ -161,7 +165,8 @@ function plot!(arrowplot::Arrows{<: Tuple{AbstractVector{<: Point{N}}, V}}) wher linesegments!( arrowplot, headstart, - color = line_c, colormap = colormap, linestyle = linestyle, + color=line_c, colormap=colormap, colorscale=colorscale, linestyle=linestyle, + colorrange=arrowplot.colorrange, linewidth=lift(lw -> lw === automatic ? 1.0f0 : lw, arrowplot, linewidth), fxaa = fxaa_bool, inspectable = inspectable, transparency = transparency, visible = visible, @@ -172,7 +177,7 @@ function plot!(arrowplot::Arrows{<: Tuple{AbstractVector{<: Point{N}}, V}}) wher marker=marker_head, markersize = lift(as-> as === automatic ? theme(scene, :markersize)[] : as, arrowplot, arrowsize), color = arrow_c, rotations = rotations, strokewidth = 0.0, - colormap = colormap, markerspace = arrowplot.markerspace, + colormap=colormap, markerspace=arrowplot.markerspace, colorrange=arrowplot.colorrange, fxaa = fxaa_bool, inspectable = inspectable, transparency = transparency, visible = visible ) @@ -205,25 +210,21 @@ function plot!(arrowplot::Arrows{<: Tuple{AbstractVector{<: Point{N}}, V}}) wher marker_tail = lift((at, q) -> arrow_tail(3, at, q), arrowplot, arrowtail, quality) meshscatter!( arrowplot, - start, rotations = directions, - marker=marker_tail, - markersize = msize, - color = line_c, colormap = colormap, - fxaa = fxaa_bool, ssao = ssao, - diffuse = diffuse, - specular = specular, shininess = shininess, inspectable = inspectable, - transparency = transparency, visible = visible + start, rotations = directions, markersize = msize, + marker = marker_tail, + color = line_c, colormap = colormap, colorscale = colorscale, colorrange = arrowplot.colorrange, + fxaa = fxaa_bool, ssao = ssao, shading = shading, + diffuse = diffuse, specular = specular, shininess = shininess, + inspectable = inspectable, transparency = transparency, visible = visible ) meshscatter!( arrowplot, - start, rotations = directions, - marker=marker_head, - markersize = markersize, - color = arrow_c, colormap = colormap, - fxaa = fxaa_bool, ssao = ssao, - diffuse = diffuse, - specular = specular, shininess = shininess, inspectable = inspectable, - transparency = transparency, visible = visible + start, rotations = directions, markersize = markersize, + marker = marker_head, + color = arrow_c, colormap = colormap, colorscale = colorscale, colorrange = arrowplot.colorrange, + fxaa = fxaa_bool, ssao = ssao, shading = shading, + diffuse = diffuse, specular = specular, shininess = shininess, + inspectable = inspectable, transparency = transparency, visible = visible ) end diff --git a/src/basic_recipes/axis.jl b/src/basic_recipes/axis.jl index f9744a8efb5..82db813c536 100644 --- a/src/basic_recipes/axis.jl +++ b/src/basic_recipes/axis.jl @@ -290,7 +290,7 @@ function draw_axis3d(textbuffer, linebuffer, scale, limits, ranges_labels, fonts end / scale[j] pos = labelposition(ranges, i, tickdir, titlegap[i] + tick_widths, origin) .+ offset2 push!( - textbuffer, to_latex(axisnames[i]), pos, + textbuffer, UnicodeFun.to_latex(axisnames[i]), pos; fontsize = axisnames_size[i], color = axisnames_color[i], rotation = axisrotation[i], align = axisalign[i], font = axisnames_font[i] ) @@ -317,8 +317,8 @@ function draw_axis3d(textbuffer, linebuffer, scale, limits, ranges_labels, fonts return end -function plot!(scene::SceneLike, ::Type{<: Axis3D}, attributes::Attributes, args...) - axis = Axis3D(scene, attributes, args) +function plot!(axis::Axis3D) + scene = get_scene(axis) # Disable any non linear transform for the axis plot! axis.transformation.transform_func[] = identity textbuffer = TextBuffer(axis, Point3, transparency = true, markerspace = :data, @@ -334,12 +334,11 @@ function plot!(scene::SceneLike, ::Type{<: Axis3D}, attributes::Attributes, args getindex.(axis, (:showaxis, :showticks, :showgrid))..., titlevals..., framevals..., tvals..., axis.padding ) - map_once( + onany( draw_axis3d, Observable(textbuffer), Observable(linebuffer), scale(scene), - axis[1], axis.ticks.ranges_labels, Observable(axis.fonts), args... + axis[1], axis.ticks.ranges_labels, Observable(axis.fonts), args...; update=true ) - push!(scene, axis) return axis end diff --git a/src/basic_recipes/band.jl b/src/basic_recipes/band.jl index b0434868c1a..9a8de39b48d 100644 --- a/src/basic_recipes/band.jl +++ b/src/basic_recipes/band.jl @@ -13,7 +13,7 @@ $(ATTRIBUTES) default_theme(scene, Mesh)..., colorrange = automatic, ) - attr[:shading][] = false + attr[:shading][] = NoShading attr end diff --git a/src/basic_recipes/barplot.jl b/src/basic_recipes/barplot.jl index 0cc49a0d488..31fe1f95b85 100644 --- a/src/basic_recipes/barplot.jl +++ b/src/basic_recipes/barplot.jl @@ -5,15 +5,15 @@ end """ bar_default_fillto(tf, ys, offset)::(ys, offset) -Returns the default y-positions and offset positions for the given transform `tf`. +Returns the default y-positions and offset positions for the given transform `tf`. -In order to customize this for your own transformation type, you can dispatch on +In order to customize this for your own transformation type, you can dispatch on `tf`. Returns a Tuple of new y positions and offset arrays. ## Arguments -- `tf`: `plot.transformation.transform_func[]`. +- `tf`: `plot.transformation.transform_func[]`. - `ys`: The y-values passed to `barplot`. - `offset`: The `offset` parameter passed to `barplot`. """ @@ -49,7 +49,9 @@ $(ATTRIBUTES) fillto = automatic, offset = 0.0, color = theme(scene, :patchcolor), + alpha = 1.0, colormap = theme(scene, :colormap), + colorscale = identity, colorrange = automatic, lowclip = automatic, highclip = automatic, @@ -76,8 +78,9 @@ $(ATTRIBUTES) color_over_bar = automatic, label_offset = 5, label_font = theme(scene, :font), - label_size = 20, + label_size = theme(scene, :fontsize), label_formatter = bar_label_formatter, + label_align = automatic, transparency = false ) end @@ -138,27 +141,39 @@ function stack_from_to(i_stack, y) end function stack_grouped_from_to(i_stack, y, grp) - from = Array{Float64}(undef, length(y)) to = Array{Float64}(undef, length(y)) - groupby = StructArray((; grp..., is_pos = y .> 0)) - + groupby = StructArray((; grp...)) grps = StructArrays.finduniquesorted(groupby) + last_pos = map(grps) do (g, inds) + g => any(y[inds] .> 0) || all(y[inds] .== 0) + end |> Dict + is_pos = map(y, groupby) do v, g + last_pos[g] = iszero(v) ? last_pos[g] : v > 0 + end + groupby = StructArray((; grp..., is_pos)) + grps = StructArrays.finduniquesorted(groupby) for (grp, inds) in grps - fromto = stack_from_to(i_stack[inds], y[inds]) - from[inds] .= fromto.from to[inds] .= fromto.to - end (from = from, to = to) end -function text_attributes(values, in_y_direction, flip_labels_at, color_over_background, color_over_bar, label_offset) +function calculate_bar_label_align(label_align, label_rotation::Real, in_y_direction::Bool, flip::Bool) + if label_align == automatic + return angle2align(-label_rotation - !flip * pi + in_y_direction * pi/2) + else + return to_align(label_align, "Failed to convert `label_align` $label_align.") + end +end + +function text_attributes(values, in_y_direction, flip_labels_at, color_over_background, color_over_bar, + label_offset, label_rotation, label_align) aligns = Vec2f[] offsets = Vec2f[] text_colors = RGBAf[] @@ -176,14 +191,17 @@ function text_attributes(values, in_y_direction, flip_labels_at, color_over_back end for (i, k) in enumerate(values) - # Plot text inside bar - if flip(k) - push!(aligns, swap(0.5, 1.0)) + + isflipped = flip(k) + + push!(aligns, calculate_bar_label_align(label_align, label_rotation, in_y_direction, isflipped)) + + if isflipped + # plot text inside bar push!(offsets, swap(0, -label_offset)) push!(text_colors, geti(color_over_bar, i)) else # plot text next to bar - push!(aligns, swap(0.5, 0.0)) push!(offsets, swap(0, label_offset)) push!(text_colors, geti(color_over_background, i)) end @@ -191,7 +209,9 @@ function text_attributes(values, in_y_direction, flip_labels_at, color_over_back return aligns, offsets, text_colors end -function barplot_labels(xpositions, ypositions, bar_labels, in_y_direction, flip_labels_at, color_over_background, color_over_bar, label_formatter, label_offset) +function barplot_labels(xpositions, ypositions, bar_labels, in_y_direction, flip_labels_at, + color_over_background, color_over_bar, label_formatter, label_offset, label_rotation, + label_align) if bar_labels isa Symbol && bar_labels in (:x, :y) bar_labels = map(xpositions, ypositions) do x, y if bar_labels === :x @@ -203,7 +223,8 @@ function barplot_labels(xpositions, ypositions, bar_labels, in_y_direction, flip end if bar_labels isa AbstractVector if length(bar_labels) == length(xpositions) - attributes = text_attributes(ypositions, in_y_direction, flip_labels_at, color_over_background, color_over_bar, label_offset) + attributes = text_attributes(ypositions, in_y_direction, flip_labels_at, color_over_background, + color_over_bar, label_offset, label_rotation, label_align) label_pos = map(xpositions, ypositions, bar_labels) do x, y, l return (string(l), in_y_direction ? Point2f(x, y) : Point2f(y, x)) end @@ -217,14 +238,17 @@ function barplot_labels(xpositions, ypositions, bar_labels, in_y_direction, flip end function Makie.plot!(p::BarPlot) - - labels = Observable(Tuple{String, Point2f}[]) + bar_points = p[1] + if !(eltype(bar_points[]) <: Point2) + error("barplot only accepts x/y coordinates. Use `barplot(x, y)` or `barplot(xy::Vector{<:Point2})`.") + end + labels = Observable(Tuple{Union{String,LaTeXStrings.LaTeXString}, Point2f}[]) label_aligns = Observable(Vec2f[]) label_offsets = Observable(Vec2f[]) label_colors = Observable(RGBAf[]) function calculate_bars(xy, fillto, offset, transformation, width, dodge, n_dodge, gap, dodge_gap, stack, dir, bar_labels, flip_labels_at, label_color, color_over_background, - color_over_bar, label_formatter, label_offset) + color_over_bar, label_formatter, label_offset, label_rotation, label_align) in_y_direction = get((y=true, x=false), dir) do error("Invalid direction $dir. Options are :x and :y.") @@ -274,7 +298,7 @@ function Makie.plot!(p::BarPlot) obar = color_over_bar === automatic ? label_color : color_over_bar label_args = barplot_labels(x̂, y, bar_labels, in_y_direction, flip_labels_at, to_color(oback), to_color(obar), - label_formatter, label_offset) + label_formatter, label_offset, label_rotation, label_align) labels[], label_aligns[], label_offsets[], label_colors[] = label_args end @@ -283,14 +307,14 @@ function Makie.plot!(p::BarPlot) bars = lift(calculate_bars, p, p[1], p.fillto, p.offset, p.transformation.transform_func, p.width, p.dodge, p.n_dodge, p.gap, p.dodge_gap, p.stack, p.direction, p.bar_labels, p.flip_labels_at, - p.label_color, p.color_over_background, p.color_over_bar, p.label_formatter, p.label_offset) - + p.label_color, p.color_over_background, p.color_over_bar, p.label_formatter, p.label_offset, p.label_rotation, p.label_align; priority = 1) poly!( - p, bars, color = p.color, colormap = p.colormap, colorrange = p.colorrange, + p, bars, color = p.color, colormap = p.colormap, colorscale = p.colorscale, colorrange = p.colorrange, strokewidth = p.strokewidth, strokecolor = p.strokecolor, visible = p.visible, inspectable = p.inspectable, transparency = p.transparency, - highclip = p.highclip, lowclip = p.lowclip, nan_color = p.nan_color, + highclip = p.highclip, lowclip = p.lowclip, nan_color = p.nan_color, alpha = p.alpha, ) + if !isnothing(p.bar_labels[]) text!(p, labels; align=label_aligns, offset=label_offsets, color=label_colors, font=p.label_font, fontsize=p.label_size, rotation=p.label_rotation) end diff --git a/src/basic_recipes/bracket.jl b/src/basic_recipes/bracket.jl index 140e20a0395..9a76143e342 100644 --- a/src/basic_recipes/bracket.jl +++ b/src/basic_recipes/bracket.jl @@ -37,7 +37,7 @@ function Makie.convert_arguments(::Type{<:Bracket}, x1::AbstractVector{<:Real}, points = broadcast(x1, y1, x2, y2) do x1, y1, x2, y2 (Point2f(x1, y1), Point2f(x2, y2)) end - (points,) + return (points,) end function Makie.plot!(pl::Bracket) @@ -48,21 +48,21 @@ function Makie.plot!(pl::Bracket) textoffset_vec = Observable(Vec2f[]) bp = Observable(BezierPath[]) - textpoints = Observable(Point2f[]) + text_tuples = Observable(Tuple{Any,Point2f}[]) realtextoffset = lift(pl, pl.textoffset, pl.fontsize) do to, fs return to === automatic ? Float32.(0.75 .* fs) : Float32.(to) end - onany(pl, points, scene.camera.projectionview, pl.model, transform_func(pl), - scene.px_area, pl.offset, pl.width, pl.orientation, realtextoffset, - pl.style) do points, _, _, _, _, offset, width, orientation, textoff, style + onany(pl, points, scene.camera.projectionview, pl.model, transform_func(pl), + scene.viewport, pl.offset, pl.width, pl.orientation, realtextoffset, + pl.style, pl.text) do points, _, _, _, _, offset, width, orientation, textoff, style, text empty!(bp[]) empty!(textoffset_vec[]) - empty!(textpoints[]) + empty!(text_tuples[]) - broadcast_foreach(points, offset, width, orientation, textoff, style) do (_p1, _p2), offset, width, orientation, textoff, style + broadcast_foreach(points, offset, width, orientation, textoff, style, text) do (_p1, _p2), offset, width, orientation, textoff, style, text p1 = plot_to_screen(pl, _p1) p2 = plot_to_screen(pl, _p2) @@ -79,12 +79,12 @@ function Makie.plot!(pl::Bracket) push!(textoffset_vec[], d2 * textoff) b, textpoint = bracket_bezierpath(style, p1 + off, p2 + off, d2, width) - push!(textpoints[], textpoint) + push!(text_tuples[], (text, textpoint)) push!(bp[], b) end notify(bp) - notify(textpoints) + notify(text_tuples) end notify(points) @@ -102,23 +102,16 @@ function Makie.plot!(pl::Bracket) return rots end - # TODO: this works around `text()` not being happy if text="xyz" comes with one-element vector attributes - texts = lift(pl, pl.text) do text - return text isa AbstractString ? [text] : text - end - # Avoid scale!() / translate!() / rotate!() to affect these - series!(pl, bp; space = :pixel, solid_color = pl.color, linewidth = pl.linewidth, + series!(pl, bp; space = :pixel, solid_color = pl.color, linewidth = pl.linewidth, linestyle = pl.linestyle, transformation = Transformation()) - text!(pl, textpoints, text = texts, space = :pixel, align = pl.align, offset = textoffset_vec, + text!(pl, text_tuples, space = :pixel, align = pl.align, offset = textoffset_vec, fontsize = pl.fontsize, font = pl.font, rotation = autorotations, color = pl.textcolor, justification = pl.justification, model = Mat4f(I)) pl end -data_limits(pl::Bracket) = mapreduce(union, pl[1][]) do points - Rect3f([points...]) -end +data_limits(pl::Bracket) = mapreduce(ps -> Rect3f([ps...]), union, pl[1][]) bracket_bezierpath(style::Symbol, args...) = bracket_bezierpath(Val(style), args...) diff --git a/src/basic_recipes/contourf.jl b/src/basic_recipes/contourf.jl index 60eb88ec6e6..89ff2ae4da3 100644 --- a/src/basic_recipes/contourf.jl +++ b/src/basic_recipes/contourf.jl @@ -32,6 +32,7 @@ $(ATTRIBUTES) levels = 10, mode = :normal, colormap = theme(scene, :colormap), + colorscale = identity, extendlow = nothing, extendhigh = nothing, # TODO, Isoband doesn't seem to support nans? @@ -47,9 +48,7 @@ end # _computed_extendlow # _computed_extendhigh -function _get_isoband_levels(levels::Int, mi, ma) - edges = Float32.(LinRange(mi, ma, levels+1)) -end +_get_isoband_levels(levels::Int, mi, ma) = Float32.(LinRange(mi, ma, levels+1)) function _get_isoband_levels(levels::AbstractVector{<:Real}, mi, ma) edges = Float32.(levels) @@ -57,18 +56,17 @@ function _get_isoband_levels(levels::AbstractVector{<:Real}, mi, ma) edges end -conversion_trait(::Type{<:Contourf}) = ContinuousSurface() +conversion_trait(::Type{<:Contourf}) = VertexGrid() function _get_isoband_levels(::Val{:normal}, levels, values) - _get_isoband_levels(levels, extrema_nan(values)...) + return _get_isoband_levels(levels, extrema_nan(values)...) end function _get_isoband_levels(::Val{:relative}, levels::AbstractVector, values) mi, ma = extrema_nan(values) - Float32.(levels .* (ma - mi) .+ mi) + return Float32.(levels .* (ma - mi) .+ mi) end - function Makie.plot!(c::Contourf{<:Tuple{<:AbstractVector{<:Real}, <:AbstractVector{<:Real}, <:AbstractMatrix{<:Real}}}) xs, ys, zs = c[1:3] @@ -92,7 +90,6 @@ function Makie.plot!(c::Contourf{<:Tuple{<:AbstractVector{<:Real}, <:AbstractVec map!(compute_highcolor, c, highcolor, c.extendhigh, c.colormap) c.attributes[:_computed_extendhigh] = highcolor is_extended_high = lift(!isnothing, c, highcolor) - PolyType = typeof(Polygon(Point2f[], [Point2f[]])) polys = Observable(PolyType[]) @@ -144,7 +141,7 @@ function Makie.plot!(c::Contourf{<:Tuple{<:AbstractVector{<:Real}, <:AbstractVec color = colors, strokewidth = 0, strokecolor = :transparent, - shading = false, + shading = NoShading, inspectable = c.inspectable, transparency = c.transparency ) @@ -160,9 +157,7 @@ inner polygons which are holes in the outer polygon. It is possible that one group has multiple outer polygons with multiple holes each. """ function _group_polys(points, ids) - polys = [points[ids .== i] for i in unique(ids)] - npolys = length(polys) polys_lastdouble = [push!(p, first(p)) for p in polys] diff --git a/src/basic_recipes/contours.jl b/src/basic_recipes/contours.jl index e0a9d98e748..861b9e249ed 100644 --- a/src/basic_recipes/contours.jl +++ b/src/basic_recipes/contours.jl @@ -22,25 +22,26 @@ To add contour labels, use `labels = true`, and pass additional label attributes $(ATTRIBUTES) """ @recipe(Contour) do scene - default = default_theme(scene) - # pop!(default, :color) - Attributes(; - default..., + attr = Attributes(; color = nothing, - colormap = theme(scene, :colormap), - colorrange = Makie.automatic, levels = 5, linewidth = 1.0, linestyle = nothing, - alpha = 1.0, enable_depth = true, transparency = false, labels = false, + labelfont = theme(scene, :font), labelcolor = nothing, # matches color by default labelformatter = contour_label_formatter, labelsize = 10, # arbitrary ) + + + MakieCore.colormap_attributes!(attr, theme(scene, :colormap)) + MakieCore.generic_plot_attributes!(attr) + + return attr end """ @@ -61,6 +62,7 @@ angle(p1::Union{Vec2f,Point2f}, p2::Union{Vec2f,Point2f})::Float32 = function label_info(lev, vertices, col) mid = ceil(Int, 0.5f0 * length(vertices)) + # take 3 pts around half segment pts = (vertices[max(firstindex(vertices), mid - 1)], vertices[mid], vertices[min(mid + 1, lastindex(vertices))]) ( lev, @@ -109,10 +111,10 @@ function to_levels(n::Integer, cnorm) range(zmin + dz; step = dz, length = n) end -conversion_trait(::Type{<: Contour3d}) = ContinuousSurface() -conversion_trait(::Type{<: Contour}) = ContinuousSurface() -conversion_trait(::Type{<: Contour{<: Tuple{X, Y, Z, Vol}}}) where {X, Y, Z, Vol} = VolumeLike() -conversion_trait(::Type{<: Contour{<: Tuple{<: AbstractArray{T, 3}}}}) where T = VolumeLike() +conversion_trait(::Type{<: Contour3d}) = VertexGrid() +conversion_trait(::Type{<: Contour}) = VertexGrid() +conversion_trait(::Type{<:Contour}, x, y, z, ::Union{Function, AbstractArray{<: Number, 3}}) = VolumeLike() +conversion_trait(::Type{<: Contour}, ::AbstractArray{<: Number, 3}) = VolumeLike() function plot!(plot::Contour{<: Tuple{X, Y, Z, Vol}}) where {X, Y, Z, Vol} x, y, z, volume = plot[1:4] @@ -144,11 +146,14 @@ function plot!(plot::Contour{<: Tuple{X, Y, Z, Vol}}) where {X, Y, Z, Vol} end end - attr = Attributes(plot) + attr = copy(Attributes(plot)) + attr[:colorrange] = cliprange attr[:colormap] = cmap attr[:algorithm] = 7 pop!(attr, :levels) + pop!(attr, :alpha) # don't apply alpha 2 times + # unused attributes pop!(attr, :labels) pop!(attr, :labelfont) @@ -158,19 +163,11 @@ function plot!(plot::Contour{<: Tuple{X, Y, Z, Vol}}) where {X, Y, Z, Vol} volume!(plot, attr, x, y, z, volume) end -function color_per_level(color, colormap, colorrange, alpha, levels) - color_per_level(to_color(color), colormap, colorrange, alpha, levels) -end - -function color_per_level(color::Colorant, colormap, colorrange, alpha, levels) - fill(color, length(levels)) -end - -function color_per_level(colors::AbstractVector, colormap, colorrange, alpha, levels) - color_per_level(to_colormap(colors), colormap, colorrange, alpha, levels) -end +color_per_level(color, args...) = color_per_level(to_color(color), args...) +color_per_level(color::Colorant, _, _, _, _, levels) = fill(color, length(levels)) +color_per_level(colors::AbstractVector, args...) = color_per_level(to_colormap(colors), args...) -function color_per_level(colors::AbstractVector{<: Colorant}, colormap, colorrange, alpha, levels) +function color_per_level(colors::AbstractVector{<: Colorant}, _, _, _, _, levels) if length(levels) == length(colors) return colors else @@ -181,14 +178,30 @@ function color_per_level(colors::AbstractVector{<: Colorant}, colormap, colorran end end -function color_per_level(::Nothing, colormap, colorrange, a, levels) +function color_per_level(::Nothing, colormap, colorscale, colorrange, a, levels) cmap = to_colormap(colormap) map(levels) do level - c = interpolated_getindex(cmap, level, colorrange) + c = interpolated_getindex(cmap, colorscale(level), colorscale.(colorrange)) RGBAf(color(c), alpha(c) * a) end end + +function contourlines(x, y, z::AbstractMatrix{ET}, levels, level_colors, labels, T) where {ET} + # Compute contours + xv, yv = to_vector(x, size(z, 1), ET), to_vector(y, size(z, 2), ET) + contours = Contours.contours(xv, yv, z, convert(Vector{ET}, levels)) + return contourlines(T, contours, level_colors, labels) +end + +function has_changed(old_args, new_args) + length(old_args) === length(new_args) || return true + for (old, new) in zip(old_args, new_args) + old != new && return true + end + false +end + function plot!(plot::T) where T <: Union{Contour, Contour3d} x, y, z = plot[1:3] zrange = lift(nan_extrema, plot, z) @@ -205,14 +218,20 @@ function plot!(plot::T) where T <: Union{Contour, Contour3d} replace_automatic!(()-> zrange, plot, :colorrange) @extract plot (labels, labelsize, labelfont, labelcolor, labelformatter) - args = @extract plot (color, colormap, colorrange, alpha) + args = @extract plot (color, colormap, colorscale, colorrange, alpha) level_colors = lift(color_per_level, plot, args..., levels) - cont_lines = lift(plot, x, y, z, levels, level_colors, labels) do x, y, z, levels, level_colors, labels - t = eltype(z) - # Compute contours - xv, yv = to_vector(x, size(z, 1), t), to_vector(y, size(z, 2), t) - contours = Contours.contours(xv, yv, z, convert(Vector{t}, levels)) - contourlines(T, contours, level_colors, labels) + args = (x, y, z, levels, level_colors, labels) + arg_values = map(to_value, args) + old_values = map(copy, arg_values) + points, colors, lev_pos_col = Observable.(contourlines(arg_values..., T); ignore_equal_values=true) + onany(plot, args...) do args... + # contourlines is expensive enough, that it's worth to copy & check against old values + # We need to copy, since the values may get mutated in place + if has_changed(old_values, args) + old_values = map(copy, args) + points[], colors[], lev_pos_col[] = contourlines(args..., T) + return + end end P = T <: Contour ? Point2f : Point3f @@ -228,17 +247,21 @@ function plot!(plot::T) where T <: Union{Contour, Contour3d} align = (:center, :center), fontsize = labelsize, font = labelfont, + transform_marker = false ) - lift(scene.camera.projectionview, scene.px_area, labels, labelcolor, labelformatter, cont_lines) do _, _, - labels, labelcolor, labelformatter, (_, _, lev_pos_col) + lift(scene.camera.projectionview, transformationmatrix(plot), scene.viewport, + labels, labelcolor, labelformatter, lev_pos_col + ) do _, _, _, labels, labelcolor, labelformatter, lev_pos_col labels || return pos = texts.positions.val; empty!(pos) rot = texts.rotation.val; empty!(rot) col = texts.color.val; empty!(col) lbl = texts.text.val; empty!(lbl) for (lev, (p1, p2, p3), color) in lev_pos_col - rot_from_horz::Float32 = angle(project(scene, p1), project(scene, p3)) + px_pos1 = project(scene, apply_transform(transform_func(plot), p1, space)) + px_pos3 = project(scene, apply_transform(transform_func(plot), p3, space)) + rot_from_horz::Float32 = angle(px_pos1, px_pos3) # transition from an angle from horizontal axis in [-π; π] # to a readable text with a rotation from vertical axis in [-π / 2; π / 2] rot_from_vert::Float32 = if abs(rot_from_horz) > 0.5f0 * π @@ -249,23 +272,30 @@ function plot!(plot::T) where T <: Union{Contour, Contour3d} push!(col, labelcolor === nothing ? color : to_color(labelcolor)) push!(rot, rot_from_vert) push!(lbl, labelformatter(lev)) - push!(pos, p1) + p = p2 # try to position label around center + isnan(p) && (p = p1) + isnan(p) && (p = p3) + push!(pos, p) end notify(texts.text) - nothing + return end - bboxes = lift(labels, texts.text) do labels, _ + bboxes = lift(labels, texts.text; ignore_equal_values=true) do labels, _ labels || return - broadcast(texts.plots[1][1].val, texts.positions.val, texts.rotation.val) do gc, pt, rot + return broadcast(texts.plots[1][1].val, texts.positions.val, texts.rotation.val) do gc, pt, rot # drop the depth component of the bounding box for 3D - Rect2f(boundingbox(gc, project(scene.camera, space, :pixel, pt), to_rotation(rot))) + px_pos = project(scene, apply_transform(transform_func(plot), pt, space)) + bb = unchecked_boundingbox(gc, to_ndim(Point3f, px_pos, 0f0), to_rotation(rot)) + isfinite_rect(bb) || return Rect2f() + Rect2f(bb) end end - masked_lines = lift(labels, bboxes) do labels, bboxes - segments = cont_lines.val[1] + masked_lines = lift(labels, bboxes, points) do labels, bboxes, segments labels || return segments + # simple heuristic to turn off masking segments (≈ less than 10 pts per contour) + count(isnan, segments) > length(segments) / 10 && return segments n = 1 bb = bboxes[n] nlab = length(bboxes) @@ -275,14 +305,14 @@ function plot!(plot::T) where T <: Union{Contour, Contour3d} if isnan(p) && n < nlab bb = bboxes[n += 1] # next segment is materialized by a NaN, thus consider next label # wireframe!(plot, bb, space = :pixel) # toggle to debug labels - elseif project(scene.camera, space, :pixel, p) in bb + elseif project(scene, apply_transform(transform_func(plot), p, space)) in bb masked[i] = nan for dir in (-1, +1) j = i while true j += dir checkbounds(Bool, segments, j) || break - project(scene.camera, space, :pixel, segments[j]) in bb || break + project(scene, apply_transform(transform_func(plot), segments[j], space)) in bb || break masked[j] = nan end end @@ -293,11 +323,15 @@ function plot!(plot::T) where T <: Union{Contour, Contour3d} lines!( plot, masked_lines; - color = lift(x -> x[2], plot, cont_lines), + color = colors, linewidth = plot.linewidth, - inspectable = plot.inspectable, - transparency = plot.transparency, - linestyle = plot.linestyle + linestyle = plot.linestyle, + visible=plot.visible, + transparency=plot.transparency, + overdraw=plot.overdraw, + inspectable=plot.inspectable, + depth_shift=plot.depth_shift, + space=plot.space ) plot end diff --git a/src/basic_recipes/convenience_functions.jl b/src/basic_recipes/convenience_functions.jl index 66d8b9702a7..e54669b9361 100644 --- a/src/basic_recipes/convenience_functions.jl +++ b/src/basic_recipes/convenience_functions.jl @@ -16,7 +16,7 @@ end showgradients( cgrads::AbstractVector{Symbol}; h = 0.0, offset = 0.2, fontsize = 0.7, - resolution = (800, length(cgrads) * 84) + size = (800, length(cgrads) * 84) )::Scene Plots the given colour gradients arranged as horizontal colourbars. @@ -24,40 +24,25 @@ If you change the offsets or the font size, you may need to change the resolutio """ function showgradients( cgrads::AbstractVector{Symbol}; - h = 0.0, - offset = 0.4, - fontsize = 0.7, - resolution = (800, length(cgrads) * 84), - monospace = true - )::Scene + size = (800, length(cgrads) * 84), + ) - scene = Scene(resolution = resolution) + f = Figure(; size=size) + ax = Axis(f[1, 1]) - map(collect(cgrads)) do cmap + labels = map(enumerate(cgrads)) do (i, cmap) c = to_colormap(cmap) - - cbar = image!( - scene, - range(0, stop = 10, length = length(c)), - range(0, stop = 1, length = length(c)), + image!( + ax, + 0..10, + i..(i+1), reshape(c, (length(c),1)) - )[end] - - cmapstr = monospace ? UnicodeFun.to_latex("\\mono{$cmap}") : string(cmap, ":") - - text!( - scene, - cmapstr, - position = Point2f(-0.1, 0.5 + h), - align = (:right, :center), - fontsize = fontsize ) - translate!(cbar, 0, h, 0) - - h -= (1 + offset) + cmapstr = string(cmap) + return ((i + (i + 1))/2, cmapstr) end - scene - + ax.yticks = (first.(labels), last.(labels)) + return f end diff --git a/src/basic_recipes/datashader.jl b/src/basic_recipes/datashader.jl new file mode 100644 index 00000000000..d9bf42b2244 --- /dev/null +++ b/src/basic_recipes/datashader.jl @@ -0,0 +1,509 @@ +# originally from https://github.com/cjdoris/ShadeYourData.jl +module Aggregation + +import Base.Threads: @threads +import Makie: Makie, (..), Rect2, widths +abstract type AggOp end + +""" + Canvas(bounds::Rect2; resolution::Tuple{Int,Int}=(800, 800), op=AggCount()) + Canvas(xmin::Number, xmax::Number, ymin::Number, ymax::Number; args...) + +# Example + +```Julia +using Makie +canvas = Canvas(-1, 1, -1, 1; op=AggCount(), resolution=(800, 800)) +aggregate!(canvas, points; point_transform=reverse, method=AggThreads()) +aggregated_values = get_aggregation(canvas; operation=equalize_histogram, local_operation=identiy) +# Recipes are defined for canvas as well and incorperate the `get_aggregation`, but `aggregate!` must be called manually. +image!(canvas; operation=equalize_histogram, local_operation=identiy, colormap=:viridis, colorrange=(0, 20)) +surface!(canvas; operation=equalize_histogram, local_operation=identiy) +``` +""" +mutable struct Canvas + bounds::Rect2{Float64} + resolution::Tuple{Int,Int} + op::AggOp + # temporaries / results + aggbuffer::Vector + pixelbuffer::Vector + data_extrema::Tuple{Float64,Float64} +end + +""" + get_aggregation(canvas::Canvas; operation=equalize_histogram, local_operation=identity, result=similar(canvas.pixelbuffer, canvas.resolution)) + +Basically does `operation(map!(local_operation, result, canvas.pixelbuffer))`, but does the correct reshaping of the flat pixelbuffer and +simplifies passing a local or global operation. +Allocates the result buffer every time and can be made non allocating by passing the correct result buffer. +""" +function get_aggregation(canvas::Canvas; operation=equalize_histogram, local_operation=identity, result=similar(canvas.pixelbuffer, canvas.resolution)) + pix_reshaped = Base.ReshapedArray(canvas.pixelbuffer, canvas.resolution, ()) + # we want to make it easy to set local_operation or operation, without them clashing, while also being able to set both! + if operation === Makie.automatic + postfunc = local_operation === identity ? Makie.equalize_histogram : identity + else + postfunc = operation + end + return postfunc(map!(local_operation, result, pix_reshaped)) +end + +Base.size(c::Canvas) = c.resolution +Base.:(==)(a::Canvas, b::Canvas) = size(a) == size(b) && (a.bounds == b.bounds) && a.op == b.op + +@inline update(a::AggOp, x, args...) = merge(a, x, embed(a, args...)) + +struct AggCount{T} <: AggOp end +AggCount() = AggCount{Int}() +null(::AggCount{T}) where {T} = zero(T) +embed(::AggCount{T}) where {T} = oneunit(T) +merge(::AggCount{T}, x::T, y::T) where {T} = x + y +value(::AggCount{T}, x::T) where {T} = x + +struct AggAny <: AggOp end +null(::AggAny) = false +embed(::AggAny) = true +merge(::AggAny, x::Bool, y::Bool) = x | y +value(::AggAny, x::Bool) = x + +struct AggSum{T} <: AggOp end +AggSum() = AggSum{Float64}() +null(::AggSum{T}) where {T} = zero(T) +embed(::AggSum{T}, x) where {T} = convert(T, x) +merge(::AggSum{T}, x::T, y::T) where {T} = x + y +value(::AggSum{T}, x::T) where {T} = x + +struct AggMean{T} <: AggOp end +AggMean() = AggMean{Float64}() +null(::AggMean{T}) where {T} = (zero(T), zero(T)) +embed(::AggMean{T}, x) where {T} = (convert(T, x), oneunit(T)) +merge(::AggMean{T}, x::Tuple{T,T}, y::Tuple{T,T}) where {T} = (x[1] + y[1], x[2] + y[2]) +value(::AggMean{T}, x::Tuple{T,T}) where {T} = float(x[1]) / float(x[2]) + +abstract type AggMethod end + +struct AggSerial <: AggMethod end +struct AggThreads <: AggMethod end + +function Canvas(xmin::Number, xmax::Number, ymin::Number, ymax::Number; args...) + return Canvas(Rect2(xmin, ymin, xmax - xmin, ymax - ymin); args...) +end + +function Canvas(bounds::Rect2; resolution::Tuple{Int,Int}=(800, 800), op=AggCount()) + xsize, ysize = resolution + n_elements = xsize * ysize + o0 = null(op) + aggbuffer = fill(o0, n_elements) + pixelbuffer = fill(o0, n_elements) + # using ReshapedArray directly like this is not advised, but as it lives only briefly it should be ok + return Canvas(Rect2{Float64}(bounds), resolution, op, aggbuffer, pixelbuffer, (o0, o0)) +end + +n_threads(::AggSerial) = 1 +n_threads(::AggThreads) = Threads.nthreads() + +function Base.resize!(canvas::Canvas, resolution::Tuple{Int,Int}, nthreads=1) + npixel = prod(resolution) + n_elements = npixel * nthreads + length(canvas.pixelbuffer) == npixel && length(canvas.aggbuffer) == n_elements && return false + canvas.resolution = resolution + Base.resize!(canvas.pixelbuffer, npixel) + Base.resize!(canvas.aggbuffer, n_elements) + return true +end + +function change_op!(canvas::Canvas, op::AggOp) + op == canvas.op && return false + o0 = null(op) + if eltype(canvas.aggbuffer) != typeof(o0) + canvas.aggbuffer = fill(o0, size(c.aggbuffer)) + canvas.pixelbuffer = fill(o0, size(c.pixelbuffer)) + end + return true +end + +using InteractiveUtils + +""" + aggregate!(c::Canvas, points; point_transform=identity, method::AggMethod=AggSerial()) + +Aggregate points into a canvas. The points are transformed by `point_transform` before aggregation. +Method can be `AggSerial()` or `AggThreads()`. +""" +function aggregate!(c::Canvas, points; point_transform=identity, method::AggMethod=AggSerial()) + resize!(c, c.resolution, n_threads(method)) # make sure we have the right size for the method + aggbuffer, pixelbuffer = c.aggbuffer, c.pixelbuffer + fill!(aggbuffer, null(c.op)) + return aggregation_implementation!(method, aggbuffer, pixelbuffer, c, c.op, points, point_transform) +end + +function aggregation_implementation!(::AggSerial, + aggbuffer::AbstractVector, pixelbuffer::AbstractVector, + c::Canvas, op::AggOp, + points, point_transform) + (xmin, ymin), (xmax, ymax) = extrema(c.bounds) + xsize, ysize = size(c) + xwidth, ywidth = widths(c.bounds) + xscale = xsize / (xwidth + eps(xwidth)) + yscale = ysize / (ywidth + eps(ywidth)) + + @assert length(aggbuffer) == xsize * ysize + @assert length(pixelbuffer) == xsize * ysize + @assert eltype(aggbuffer) === typeof(null(op)) "$(eltype(aggbuffer)) !== $(typeof(null(op)))" + + # using ReshapedArray directly like this is not advised, but as it lives only briefly it should be ok + out = Base.ReshapedArray(aggbuffer, (xsize, ysize), ()) + for point in points + p = point_transform(point) + x = p[1] + y = p[2] + if length(p) > 2 # should compile away + z = p[3] + end + xmin ≤ x ≤ xmax || continue + ymin ≤ y ≤ ymax || continue + i = 1 + floor(Int, xscale * (x - xmin)) + j = 1 + floor(Int, yscale * (y - ymin)) + if length(p) == 2 # should compile away + out[i, j] = update(op, out[i, j]) + elseif length(p) == 3 + out[i, j] = update(op, out[i, j], z) + end + end + + mini, maxi = Inf, -Inf + map!(pixelbuffer, aggbuffer) do x + final_value = value(op, x) + if isfinite(final_value) + mini = min(final_value, mini) + maxi = max(final_value, maxi) + end + return final_value + end + c.data_extrema = (mini, maxi) + return c +end + +function aggregation_implementation!(::AggThreads, + aggbuffer::AbstractVector, pixelbuffer::AbstractVector, + c::Canvas, op::AggOp, + points, point_transform) + (xmin, ymin), (xmax, ymax) = extrema(c.bounds) + xsize, ysize = size(c) + # by adding eps to width we can use the scaling factor plus floor directly to compute the bin indices + xwidth = xmax - xmin + xscale = xsize / (xwidth + eps(xwidth)) + ywidth = ymax - ymin + yscale = ysize / (ywidth + eps(ywidth)) + # each thread reduces some of the data separately + @assert length(aggbuffer) == Threads.nthreads() * xsize * ysize + @assert length(pixelbuffer) == xsize * ysize + @assert eltype(aggbuffer) === typeof(null(op)) "$(eltype(aggbuffer)) !== $(typeof(null(op)))" + + # using ReshapedArray directly like this is not advised, but as it lives only briefly it should be ok + # https://stackoverflow.com/questions/41781621/resizing-a-matrix/41804908#41804908 + out = Base.ReshapedArray(aggbuffer, (xsize, ysize, Threads.nthreads()), ()) + out2 = Base.ReshapedArray(pixelbuffer, (xsize, ysize), ()) + + n = length(points) + chunks = round.(Int, range(1, n; length=Threads.nthreads() + 1)) + + @threads for t in 1:Threads.nthreads() + from = chunks[t] + to = chunks[t + 1] + @inbounds for idx in from:to + p = point_transform(points[idx]) + x = p[1] + y = p[2] + if length(p) > 2 # should compile away + z = p[3] + end + xmin ≤ x ≤ xmax || continue + ymin ≤ y ≤ ymax || continue + i = 1 + floor(Int, xscale * (x - xmin)) + j = 1 + floor(Int, yscale * (y - ymin)) + if length(p) == 2 # should compile away + out[i, j, t] = update(op, out[i, j, t]) + elseif length(p) == 3 + out[i, j, t] = update(op, out[i, j, t], z) + end + end + end + # reduce along the thread dimension + mini, maxi = Inf, -Inf + for j in 1:ysize + @inbounds for i in 1:xsize + val = out[i, j, 1] + for t in 2:Threads.nthreads() + val = merge(op, val, out[i, j, t]) + end + # update the value in out2 directly in this loop + final_value = value(op, val) + if isfinite(final_value) + mini = min(final_value, mini) + maxi = max(final_value, maxi) + end + out2[i, j] = final_value + end + end + c.data_extrema = (mini, maxi) + return c +end + +export AggAny, AggCount, AggMean, AggSum, AggSerial, AggThreads + +end + +using ..Aggregation +using ..Aggregation: Canvas, change_op!, aggregate! + +function equalize_histogram(matrix; nbins=256) + h_eq = StatsBase.fit(StatsBase.Histogram, vec(matrix); nbins=nbins) + h_eq = normalize(h_eq; mode=:density) + cdf = cumsum(h_eq.weights) + cdf = cdf / cdf[end] + edg = h_eq.edges[1] + # TODO is this the correct linear interpolation? + return Makie.interpolated_getindex.((cdf,), matrix, (Vec2f(first(edg), last(edg)),)) +end + +""" + datashader(points::AbstractVector{<: Point}) + +!!! warning + This feature might change outside breaking releases, since the API is not yet finalized. + Please be vary of bugs in the implementation and open issues if you encounter odd behaviour. + +Points can be any array type supporting iteration & getindex, including memory mapped arrays. +If you have separate arrays for x and y coordinates and want to avoid conversion and copy, consider using: +```Julia +using Makie.StructArrays +points = StructArray{Point2f}((x, y)) +datashader(points) +``` +Do pay attention though, that if x and y don't have a fast iteration/getindex implemented, this might be slower then just copying it into a new array. + +For best performance, use `method=Makie.AggThreads()` and make sure to start julia with `julia -tauto` or have the environment variable `JULIA_NUM_THREADS` set to the number of cores you have. + +## Attributes + +### Specific to `DataShader` + +- `agg = AggCount()` can be `AggCount()`, `AggAny()` or `AggMean()`. User extendable by overloading: + + + ```Julia + struct MyAgg{T} <: Makie.AggOp end + MyAgg() = MyAgg{Float64}() + Makie.Aggregation.null(::MyAgg{T}) where {T} = zero(T) + Makie.Aggregation.embed(::MyAgg{T}, x) where {T} = convert(T, x) + Makie.Aggregation.merge(::MyAgg{T}, x::T, y::T) where {T} = x + y + Makie.Aggregation.value(::MyAgg{T}, x::T) where {T} = x + ``` + +- `method = AggThreads()` can be `AggThreads()` or `AggSerial()`. +- `async::Bool = true` will calculate get_aggregation in a task, and skip any zoom/pan updates while busy. Great for interaction, but must be disabled for saving to e.g. png or when inlining in documenter. + +- `operation::Function = automatic` Defaults to `Makie.equalize_histogram` function which gets called on the whole get_aggregation array before display (`operation(final_aggregation_result)`). +- `local_operation::Function = identity` function which gets call on each element after the aggregation (`map!(x-> local_operation(x), final_aggregation_result)`). + +- `point_transform::Function = identity` function which gets applied to every point before aggregating it. +- `binsize::Number = 1` factor defining how many bins one wants per screen pixel. Set to n > 1 if you want a corser image. +- `show_timings::Bool = false` show how long it takes to aggregate each frame. +- `interpolate::Bool = true` If the resulting image should be displayed interpolated. + +$(Base.Docs.doc(MakieCore.colormap_attributes!)) + +$(Base.Docs.doc(MakieCore.generic_plot_attributes!)) +""" +@recipe(DataShader, points) do scene + attr = Theme( + + agg = AggCount(), + method = AggThreads(), + async = true, + # Defaults to equalize_histogram + # just set to automatic, so that if one sets local_operation, one doesn't do equalize_histogram on top of things. + operation=automatic, + local_operation=identity, + + point_transform = identity, + binsize = 1, + show_timings = false, + + interpolate = true + ) + MakieCore.generic_plot_attributes!(attr) + return MakieCore.colormap_attributes!(attr, theme(scene, :colormap)) +end + +function fast_bb(points, f) + N = length(points) + NT = Threads.nthreads() + slices = ceil(Int, N / NT) + results = fill(Point2f(0), NT, 2) + Threads.@threads for i in 1:NT + start = ((i - 1) * slices + 1) + stop = min(length(points), i * slices) + pmin, pmax = extrema(Rect2f(view(points, start:stop))) + results[i, 1] = f(pmin) + results[i, 2] = f(pmax) + end + return Rect3f(Rect2f(vec(results))) +end + + +function canvas_obs(limits::Observable, pixel_area::Observable, op, binsize::Observable) + canvas = Canvas(limits[]; resolution=(widths(pixel_area[])...,), op=op[]) + canvas_obs = Observable(canvas) + onany(limits, pixel_area, binsize, op) do lims, pxarea, binsize, op + binsize isa Int || error("Bin factor $binsize is not an Int.") + xsize, ysize = round.(Int, Makie.widths(pxarea) ./ binsize) + has_changed = Base.resize!(canvas, (xsize, ysize)) + has_changed = has_changed || change_op!(canvas, op) + lims64 = Rect2{Float64}(lims) + if canvas.bounds != lims64 + has_changed = true + canvas.bounds = lims64 + end + if has_changed + canvas_obs[] = canvas + end + end + return canvas_obs +end + +function Makie.plot!(p::DataShader{<: Tuple{<: AbstractVector{<: Point}}}) + scene = parent_scene(p) + limits = lift(projview_to_2d_limits, p, scene.camera.projectionview; ignore_equal_values=true) + viewport = lift(identity, p, scene.viewport; ignore_equal_values=true) + canvas = canvas_obs(limits, viewport, p.agg, p.binsize) + p._boundingbox = lift(fast_bb, p.points, p.point_transform) + on_func = p.async[] ? onany_latest : onany + canvas_with_aggregation = Observable(canvas[]) # Canvas that only gets notified after get_aggregation happened + p.canvas = canvas_with_aggregation + colorrange = Observable(Vec2f(0, 1)) + on(p.colorrange; update=true) do crange + if !(crange isa Automatic) + colorrange[] = Vec2f(crange) + end + end + + on_func(canvas, p.points, p.point_transform) do canvas, points, f + Aggregation.aggregate!(canvas, points; point_transform=f, method=p.method[]) + canvas_with_aggregation[] = canvas + # If not automatic, it will get updated by the above on(p.colorrange) + if p.colorrange[] isa Automatic + colorrange[] = Vec2f(distinct_extrema_nan(canvas.data_extrema)) + end + return + end + p.raw_colorrange = colorrange + image!(p, canvas_with_aggregation; + operation=p.operation, local_operation=p.local_operation, interpolate=p.interpolate, + MakieCore.generic_plot_attributes(p)..., + MakieCore.colormap_attributes(p)...) + return p +end + + +function aggregate_categories!(canvases, categories; method=AggThreads()) + for (k, canvas) in canvases + points = categories[k] + Aggregation.aggregate!(canvas, points; method=method) + end +end + +Makie.convert_arguments(::Type{<:DataShader}, x::Dict{String,Vector{<:Point2}}) = (x,) + +function Makie.convert_arguments(::Type{<:DataShader}, groups::AbstractVector, points::AbstractVector{<:Point2}) + if length(groups) != length(points) + error("Each point needs a group. Length $(length(groups)) != $(length(points))") + end + categories = Dict{String, Vector{Point2f}}() + for (g, p) in zip(groups, points) + gpoints = get!(()-> Point2f[], categories, string(g)) + push!(gpoints, p) + end + return (categories,) +end + +function Makie.plot!(p::DataShader{<:Tuple{Dict{String, Vector{Point{2, Float32}}}}}) + scene = parent_scene(p) + limits = lift(projview_to_2d_limits, p, scene.camera.projectionview; ignore_equal_values=true) + viewport = lift(identity, p, scene.viewport; ignore_equal_values=true) + canvas = canvas_obs(limits, viewport, Observable(AggCount{Float32}()), p.binsize) + p._boundingbox = lift(p.points, p.point_transform) do cats, func + rects = map(points -> fast_bb(points, func), values(cats)) + return reduce(union, rects) + end + categories = p.points[] + canvases = Dict(k => Canvas(canvas[].bounds; resolution=canvas[].resolution, op=AggCount{Float32}()) + for (k, v) in categories) + + on_func = p.async[] ? onany_latest : onany + canvas_with_aggregation = Observable(canvas[]) # Canvas that only gets notified after get_aggregation happened + p.canvas = canvas_with_aggregation + toal_value = Observable(0f0) + on_func(canvas, p.points) do canvas, cats + for (k, c) in canvases + Base.resize!(c, canvas.resolution) + c.bounds = canvas.bounds + end + aggregate_categories!(canvases, cats; method=p.method[]) + toal_value[] = Float32(maximum(sum(map(x -> x.pixelbuffer, values(canvases))))) + return + end + colors = Dict(k => Makie.wong_colors()[i] for (i, (k, v)) in enumerate(categories)) + p._categories = colors + op = map(total -> (x -> log10(x + 1) / log10(total + 1)), toal_value) + for (k, canvas) in canvases + color = colors[k] + cmap = [(color, 0.0), (color, 1.0)] + image!(p, canvas; colorrange=Vec2f(0, 1), colormap=cmap, operation=identity, local_operation=op) + end + return p +end + +data_limits(p::DataShader) = p._boundingbox[] + +used_attributes(::Canvas) = (:operation, :local_operation) + +function convert_arguments(P::Type{<:Union{MeshScatter,Image,Surface,Contour,Contour3d}}, canvas::Canvas; + operation=automatic, local_operation=identity) + pixel = Aggregation.get_aggregation(canvas; operation=operation, local_operation=local_operation) + (xmin, ymin), (xmax, ymax) = extrema(canvas.bounds) + return convert_arguments(P, xmin .. xmax, ymin .. ymax, pixel) +end + +# TODO improve color legend API, to not need a fake plot like this +struct FakePlot <: AbstractPlot{Poly} + attributes::Attributes +end +Base.getindex(x::FakePlot, key::Symbol) = getindex(getfield(x, :attributes), key) + +function get_plots(plot::DataShader) + return map(collect(plot._categories[])) do (name, color) + return FakePlot(Attributes(; label=name, color=color)) + end +end + +function legendelements(plot::FakePlot, legend) + return [PolyElement(; color=plot.attributes.color, strokecolor=legend.polystrokecolor, strokewidth=legend.polystrokewidth)] +end + +# Sadly we must define the colorbar here and cant use the default fallback, +# Since the Image plot will only see the scaled data, and since its hard to make Colorbar support the equalize_histogram +# transform, we just create the colorbar form the raw data. +# TODO, should we merge the local/global op with colorscale? +function extract_colormap(plot::DataShader) + color = map(x -> x.aggbuffer, plot.canvas) + return ColorMapping( + color[], color, plot.colormap, plot.raw_colorrange, + plot.colorscale, + plot.alpha, + plot.highclip, + plot.lowclip, + plot.nan_color) +end diff --git a/src/basic_recipes/error_and_rangebars.jl b/src/basic_recipes/error_and_rangebars.jl index d49e7f7652b..95dc2ec39e1 100644 --- a/src/basic_recipes/error_and_rangebars.jl +++ b/src/basic_recipes/error_and_rangebars.jl @@ -25,9 +25,11 @@ $(ATTRIBUTES) direction = :y, visible = theme(scene, :visible), colormap = theme(scene, :colormap), + colorscale = identity, colorrange = automatic, inspectable = theme(scene, :inspectable), - transparency = false + transparency = false, + cycle = [:color] ) end @@ -53,9 +55,11 @@ $(ATTRIBUTES) direction = :y, visible = theme(scene, :visible), colormap = theme(scene, :colormap), + colorscale = identity, colorrange = automatic, inspectable = theme(scene, :inspectable), - transparency = false + transparency = false, + cycle = [:color] ) end @@ -141,11 +145,15 @@ function Makie.plot!(plot::Errorbars{T}) where T <: Tuple{AbstractVector{<:VecTy end linesegpairs = lift(plot, x_y_low_high, is_in_y_direction) do x_y_low_high, in_y - return map(x_y_low_high) do (x, y, l, h) - in_y ? - (Point2f(x, y - l), Point2f(x, y + h)) : - (Point2f(x - l, y), Point2f(x + h, y)) + output = sizehint!(Point2f[], 2length(x_y_low_high)) + for (x, y, l, h) in x_y_low_high + if in_y + push!(output, Point2f(x, y - l), Point2f(x, y + h)) + else + push!(output, Point2f(x - l, y), Point2f(x + h, y)) + end end + return output end _plot_bars!(plot, linesegpairs, is_in_y_direction) @@ -167,11 +175,15 @@ function Makie.plot!(plot::Rangebars{T}) where T <: Tuple{AbstractVector{<:VecTy end linesegpairs = lift(plot, val_low_high, is_in_y_direction) do vlh, in_y - return map(vlh) do (v, l, h) - in_y ? - (Point2f(v, l), Point2f(v, h)) : - (Point2f(l, v), Point2f(h, v)) + output = sizehint!(Point2f[], 2length(vlh)) + for (v, l, h) in vlh + if in_y + push!(output, Point2f(v, l), Point2f(v, h)) + else + push!(output, Point2f(l, v), Point2f(h, v)) + end end + return output end _plot_bars!(plot, linesegpairs, is_in_y_direction) @@ -183,19 +195,18 @@ function _plot_bars!(plot, linesegpairs, is_in_y_direction) f_if(condition, f, arg) = condition ? f(arg) : arg - @extract plot (whiskerwidth, color, linewidth, visible, colormap, colorrange, inspectable, transparency) + @extract plot (whiskerwidth, color, linewidth, visible, colormap, colorscale, colorrange, inspectable, transparency) scene = parent_scene(plot) whiskers = lift(plot, linesegpairs, scene.camera.projectionview, plot.model, - scene.px_area, transform_func(plot), whiskerwidth) do pairs, _, _, _, _, whiskerwidth + scene.viewport, transform_func(plot), whiskerwidth) do endpoints, _, _, _, _, whiskerwidth - endpoints = [p for pair in pairs for p in pair] screenendpoints = plot_to_screen(plot, endpoints) screenendpoints_shifted_pairs = map(screenendpoints) do sep (sep .+ f_if(is_in_y_direction[], reverse, Point(0, -whiskerwidth/2)), - sep .+ f_if(is_in_y_direction[], reverse, Point(0, whiskerwidth/2))) + sep .+ f_if(is_in_y_direction[], reverse, Point(0, whiskerwidth/2))) end return [p for pair in screenendpoints_shifted_pairs for p in pair] @@ -222,12 +233,12 @@ function _plot_bars!(plot, linesegpairs, is_in_y_direction) linesegments!( plot, linesegpairs, color = color, linewidth = linewidth, visible = visible, - colormap = colormap, colorrange = colorrange, inspectable = inspectable, + colormap = colormap, colorscale = colorscale, colorrange = colorrange, inspectable = inspectable, transparency = transparency ) linesegments!( plot, whiskers, color = whiskercolors, linewidth = whiskerlinewidths, - visible = visible, colormap = colormap, colorrange = colorrange, + visible = visible, colormap = colormap, colorscale = colorscale, colorrange = colorrange, inspectable = inspectable, transparency = transparency, space = :pixel, model = Mat4f(I) # overwrite scale!() / translate!() / rotate!() ) diff --git a/src/basic_recipes/hvlines.jl b/src/basic_recipes/hvlines.jl index a7ead9e9d09..a919629d6b6 100644 --- a/src/basic_recipes/hvlines.jl +++ b/src/basic_recipes/hvlines.jl @@ -57,7 +57,6 @@ function Makie.plot!(p::Union{HLines, VLines}) ma = p isa HLines ? p.xmax : p.ymax onany(p, limits, p[1], mi, ma, transf) do lims, vals, mi, ma, transf - inv = inverse_transform(transf) empty!(points[]) min_x, min_y = minimum(lims) max_x, max_y = maximum(lims) @@ -65,15 +64,13 @@ function Makie.plot!(p::Union{HLines, VLines}) if p isa HLines x_mi = min_x + (max_x - min_x) * mi x_ma = min_x + (max_x - min_x) * ma - x_mi = _apply_x_transform(inv, x_mi) - x_ma = _apply_x_transform(inv, x_ma) + val = _apply_y_transform(transf, val) push!(points[], Point2f(x_mi, val)) push!(points[], Point2f(x_ma, val)) elseif p isa VLines y_mi = min_y + (max_y - min_y) * mi y_ma = min_y + (max_y - min_y) * ma - y_mi = _apply_y_transform(inv, y_mi) - y_ma = _apply_y_transform(inv, y_ma) + val = _apply_x_transform(transf, val) push!(points[], Point2f(val, y_mi)) push!(points[], Point2f(val, y_ma)) end @@ -84,7 +81,27 @@ function Makie.plot!(p::Union{HLines, VLines}) notify(p[1]) line_attributes = copy(p.attributes) - delete!.(line_attributes, (:ymin, :ymax, :yautolimits)) + foreach(key-> delete!(line_attributes, key), [:ymin, :ymax, :xmin, :xmax, :xautolimits, :yautolimits]) + # Drop transform_func because we handle it manually + line_attributes[:transformation] = Transformation(p, transform_func = identity) linesegments!(p, line_attributes, points) p end + +function data_limits(p::HLines) + scene = parent_scene(p) + limits = projview_to_2d_limits(scene.camera.projectionview[]) + itf = inverse_transform(p.transformation.transform_func[]) + xmin, xmax = apply_transform.(itf[1], first.(extrema(limits))) + ymin, ymax = extrema(p[1][]) + return Rect3f(Point3f(xmin, ymin, 0), Vec3f(xmax - xmin, ymax - ymin, 0)) +end + +function data_limits(p::VLines) + scene = parent_scene(p) + limits = projview_to_2d_limits(scene.camera.projectionview[]) + itf = inverse_transform(p.transformation.transform_func[]) + xmin, xmax = extrema(p[1][]) + ymin, ymax = apply_transform.(itf[2], getindex.(extrema(limits), 2)) + return Rect3f(Point3f(xmin, ymin, 0), Vec3f(xmax - xmin, ymax - ymin, 0)) +end \ No newline at end of file diff --git a/src/basic_recipes/hvspan.jl b/src/basic_recipes/hvspan.jl index 18ac06bdae4..cb082347705 100644 --- a/src/basic_recipes/hvspan.jl +++ b/src/basic_recipes/hvspan.jl @@ -48,9 +48,8 @@ function Makie.plot!(p::Union{HSpan, VSpan}) mi = p isa HSpan ? p.xmin : p.ymin ma = p isa HSpan ? p.xmax : p.ymax - + onany(limits, p[1], p[2], mi, ma, transf) do lims, lows, highs, mi, ma, transf - inv = inverse_transform(transf) empty!(rects[]) min_x, min_y = minimum(lims) max_x, max_y = maximum(lims) @@ -58,14 +57,14 @@ function Makie.plot!(p::Union{HSpan, VSpan}) if p isa HSpan x_mi = min_x + (max_x - min_x) * mi x_ma = min_x + (max_x - min_x) * ma - x_mi = _apply_x_transform(inv, x_mi) - x_ma = _apply_x_transform(inv, x_ma) + low = _apply_y_transform(transf, low) + high = _apply_y_transform(transf, high) push!(rects[], Rect2f(Point2f(x_mi, low), Vec2f(x_ma - x_mi, high - low))) elseif p isa VSpan y_mi = min_y + (max_y - min_y) * mi y_ma = min_y + (max_y - min_y) * ma - y_mi = _apply_y_transform(inv, y_mi) - y_ma = _apply_y_transform(inv, y_ma) + low = _apply_x_transform(transf, low) + high = _apply_x_transform(transf, high) push!(rects[], Rect2f(Point2f(low, y_mi), Vec2f(high - low, y_ma - y_mi))) end end @@ -74,7 +73,12 @@ function Makie.plot!(p::Union{HSpan, VSpan}) notify(p[1]) - poly!(p, rects; p.attributes...) + poly_attributes = copy(p.attributes) + foreach(x-> delete!(poly_attributes, x), [:ymin, :ymax, :xmin, :xmax, :xautolimits, :yautolimits]) + + # we handle transform_func manually + poly_attributes[:transformation] = Transformation(p, transform_func = identity) + poly!(p, poly_attributes, rects) p end @@ -84,3 +88,24 @@ _apply_x_transform(other, v) = error("x transform not defined for transform func _apply_y_transform(t::Tuple, v) = apply_transform(t[2], v) _apply_y_transform(other, v) = error("y transform not defined for transform function $(typeof(other))") _apply_y_transform(::typeof(identity), v) = v + + +function data_limits(p::HSpan) + scene = parent_scene(p) + limits = projview_to_2d_limits(scene.camera.projectionview[]) + itf = inverse_transform(p.transformation.transform_func[]) + xmin, xmax = apply_transform.(itf[1], first.(extrema(limits))) + ymin = minimum(p[1][]) + ymax = maximum(p[2][]) + return Rect3f(Point3f(xmin, ymin, 0), Vec3f(xmax - xmin, ymax - ymin, 0)) +end + +function data_limits(p::VSpan) + scene = parent_scene(p) + limits = projview_to_2d_limits(scene.camera.projectionview[]) + itf = inverse_transform(p.transformation.transform_func[]) + xmin = minimum(p[1][]) + xmax = maximum(p[2][]) + ymin, ymax = apply_transform.(itf[2], getindex.(extrema(limits), 2)) + return Rect3f(Point3f(xmin, ymin, 0), Vec3f(xmax - xmin, ymax - ymin, 0)) +end \ No newline at end of file diff --git a/src/basic_recipes/poly.jl b/src/basic_recipes/poly.jl index efd57008854..8792eadfb0c 100644 --- a/src/basic_recipes/poly.jl +++ b/src/basic_recipes/poly.jl @@ -9,53 +9,85 @@ convert_arguments(::Type{<: Poly}, m::GeometryBasics.Mesh) = (m,) convert_arguments(::Type{<: Poly}, m::GeometryBasics.GeometryPrimitive) = (m,) function plot!(plot::Poly{<: Tuple{Union{GeometryBasics.Mesh, GeometryPrimitive}}}) + mesh!( plot, lift(triangle_mesh, plot, plot[1]), - color = plot[:color], - colormap = plot[:colormap], - colorrange = plot[:colorrange], - lowclip = plot[:lowclip], - highclip = plot[:highclip], - nan_color = plot[:nan_color], - shading = plot[:shading], - visible = plot[:visible], - overdraw = plot[:overdraw], - inspectable = plot[:inspectable], - transparency = plot[:transparency], - space = plot[:space] + color = plot.color, + colormap = plot.colormap, + colorscale = plot.colorscale, + colorrange = plot.colorrange, + alpha = plot.alpha, + lowclip = plot.lowclip, + highclip = plot.highclip, + nan_color = plot.nan_color, + shading = plot.shading, + visible = plot.visible, + overdraw = plot.overdraw, + inspectable = plot.inspectable, + transparency = plot.transparency, + space = plot.space ) wireframe!( plot, plot[1], color = plot[:strokecolor], linestyle = plot[:linestyle], space = plot[:space], linewidth = plot[:strokewidth], visible = plot[:visible], overdraw = plot[:overdraw], - inspectable = plot[:inspectable], transparency = plot[:transparency] + inspectable = plot[:inspectable], transparency = plot[:transparency], + colormap = plot[:strokecolormap] ) end # Poly conversion -function poly_convert(geometries) +function poly_convert(geometries::AbstractVector, transform_func=identity) isempty(geometries) && return typeof(GeometryBasics.Mesh(Point2f[], GLTriangleFace[]))[] - return triangle_mesh.(geometries) + return poly_convert.(geometries, (transform_func,)) end -poly_convert(meshes::AbstractVector{<:AbstractMesh}) = meshes -poly_convert(polys::AbstractVector{<:Polygon}) = triangle_mesh.(polys) -function poly_convert(multipolygons::AbstractVector{<:MultiPolygon}) - return [merge(triangle_mesh.(multipoly.polygons)) for multipoly in multipolygons] + +function poly_convert(geometry::AbstractGeometry, transform_func=identity) + return GeometryBasics.triangle_mesh(geometry) end -poly_convert(mesh::GeometryBasics.Mesh) = mesh +poly_convert(meshes::AbstractVector{<:AbstractMesh}, transform_func=identity) = poly_convert.(meshes, (transform_func,)) -poly_convert(polygon::Polygon) = triangle_mesh(polygon) +function poly_convert(polys::AbstractVector{<:Polygon}, transform_func=identity) + # GLPlainMesh2D is not concrete? + T = GeometryBasics.Mesh{2, Float32, GeometryBasics.Ngon{2, Float32, 3, Point2f}, SimpleFaceView{2, Float32, 3, GLIndex, Point2f, GLTriangleFace}} + return isempty(polys) ? T[] : poly_convert.(polys, (transform_func,)) +end -function poly_convert(polygon::AbstractVector{<: VecTypes}) - return poly_convert([convert_arguments(Scatter, polygon)[1]]) +function poly_convert(multipolygons::AbstractVector{<:MultiPolygon}, transform_func=identity) + return [merge(poly_convert.(multipoly.polygons, (transform_func,))) for multipoly in multipolygons] end -function poly_convert(polygons::AbstractVector{<: AbstractVector{<: VecTypes}}) +poly_convert(mesh::GeometryBasics.Mesh, transform_func=identity) = mesh + +function poly_convert(polygon::Polygon, transform_func=identity) + outer = metafree(coordinates(polygon.exterior)) + points = Vector{Point2f}[apply_transform(transform_func, outer)] + points_flat = Point2f[outer;] + for inner in polygon.interiors + inner_points = metafree(coordinates(inner)) + append!(points_flat, inner_points) + push!(points, apply_transform(transform_func, inner_points)) + end + # Triangulate on transformed points, but leave the original points in the mesh + # We sadly need to do this right now, since otherwise + # The transformed points will mess with data_limits and the axes. + # TODO, leave triangulations to the backend, and just pass the untransformed points + faces = GeometryBasics.earcut_triangulate(points) + return GeometryBasics.Mesh(points_flat, faces) +end + +function poly_convert(polygon::AbstractVector{<:VecTypes}, transform_func=identity) + point2f = convert(Vector{Point2f}, polygon) + points_transformed = apply_transform(transform_func, point2f) + faces = GeometryBasics.earcut_triangulate([points_transformed]) + # TODO, same as above! + return GeometryBasics.Mesh(point2f, faces) +end + +function poly_convert(polygons::AbstractVector{<:AbstractVector{<:VecTypes}}, transform_func=identity) return map(polygons) do poly - point2f = convert(Vector{Point2f}, poly) - faces = GeometryBasics.earcut_triangulate([point2f]) - return GeometryBasics.Mesh(point2f, faces) + return poly_convert(poly, transform_func) end end @@ -79,27 +111,30 @@ end function to_lines(polygon::AbstractVector{<: VecTypes}) result = Point2f.(polygon) - push!(result, polygon[1]) + isempty(result) || push!(result, polygon[1]) return result end function plot!(plot::Poly{<: Tuple{<: Union{Polygon, AbstractVector{<: PolyElements}}}}) geometries = plot[1] - meshes = lift(poly_convert, plot, geometries) + transform_func = plot.transformation.transform_func + meshes = lift(poly_convert, plot, geometries, transform_func) mesh!(plot, meshes; visible = plot.visible, shading = plot.shading, color = plot.color, colormap = plot.colormap, + colorscale = plot.colorscale, colorrange = plot.colorrange, lowclip = plot.lowclip, highclip = plot.highclip, - nan_color = plot.nan_color, + nan_color=plot.nan_color, + alpha=plot.alpha, overdraw = plot.overdraw, fxaa = plot.fxaa, transparency = plot.transparency, inspectable = plot.inspectable, - space = plot.space + space = plot.space, ) outline = lift(to_lines, plot, geometries) @@ -119,7 +154,8 @@ function plot!(plot::Poly{<: Tuple{<: Union{Polygon, AbstractVector{<: PolyEleme lines!( plot, outline, visible = plot.visible, - color = stroke, linestyle = plot.linestyle, + color = stroke, linestyle = plot.linestyle, alpha = plot.alpha, + colormap = plot.strokecolormap, linewidth = plot.strokewidth, space = plot.space, overdraw = plot.overdraw, transparency = plot.transparency, inspectable = plot.inspectable, depth_shift = -1f-5 @@ -128,15 +164,16 @@ end function plot!(plot::Mesh{<: Tuple{<: AbstractVector{P}}}) where P <: Union{AbstractMesh, Polygon} meshes = plot[1] - color_node = plot.color attributes = Attributes( visible = plot.visible, shading = plot.shading, fxaa = plot.fxaa, inspectable = plot.inspectable, transparency = plot.transparency, space = plot.space, ssao = plot.ssao, + alpha=plot.alpha, lowclip = get(plot, :lowclip, automatic), highclip = get(plot, :highclip, automatic), nan_color = get(plot, :nan_color, :transparent), colormap = get(plot, :colormap, nothing), + colorscale = get(plot, :colorscale, identity), colorrange = get(plot, :colorrange, automatic) ) @@ -144,15 +181,15 @@ function plot!(plot::Mesh{<: Tuple{<: AbstractVector{P}}}) where P <: Union{Abst return Int[length(coordinates(m)) for m in meshes] end - mesh_colors = Observable{Union{AbstractPattern, Matrix{RGBAf}, RGBColors}}() + mesh_colors = Observable{Union{AbstractPattern, Matrix{RGBAf}, RGBColors, Float32}}() map!(plot, mesh_colors, plot.color, num_meshes) do colors, num_meshes # one mesh per color - c_converted = to_color(colors) - if c_converted isa AbstractVector && length(c_converted) == length(num_meshes) - result = similar(c_converted, sum(num_meshes)) + if colors isa AbstractVector && length(colors) == length(num_meshes) + ccolors = colors isa AbstractArray{<: Number} ? colors : to_color(colors) + result = similar(ccolors, float32type(ccolors), sum(num_meshes)) i = 1 - for (cs, len) in zip(c_converted, num_meshes) + for (cs, len) in zip(ccolors, num_meshes) for j in 1:len result[i] = cs i += 1 @@ -164,16 +201,18 @@ function plot!(plot::Mesh{<: Tuple{<: AbstractVector{P}}}) where P <: Union{Abst else # If we have colors per vertex, we need to interpolate in fragment shader attributes[:interpolate_in_fragment_shader] = true - return c_converted + return to_color(colors) end end attributes[:color] = mesh_colors - bigmesh = lift(plot, meshes) do meshes + transform_func = plot.transformation.transform_func + bigmesh = lift(plot, meshes, transform_func) do meshes, tf if isempty(meshes) return GeometryBasics.Mesh(Point2f[], GLTriangleFace[]) else - return merge(GeometryBasics.triangle_mesh.(meshes)) + triangle_meshes = map(mesh -> poly_convert(mesh, tf), meshes) + return merge(triangle_meshes) end end - mesh!(plot, attributes, bigmesh) + return mesh!(plot, attributes, bigmesh) end diff --git a/src/basic_recipes/scatterlines.jl b/src/basic_recipes/scatterlines.jl index e868c58f577..cc2e02d4ec8 100644 --- a/src/basic_recipes/scatterlines.jl +++ b/src/basic_recipes/scatterlines.jl @@ -12,12 +12,13 @@ $(ATTRIBUTES) Attributes( color = l_theme.color, colormap = l_theme.colormap, + colorscale = l_theme.colorscale, colorrange = get(l_theme.attributes, :colorrange, automatic), linestyle = l_theme.linestyle, linewidth = l_theme.linewidth, markercolor = automatic, - markercolormap = s_theme.colormap, - markercolorrange = get(s_theme.attributes, :colorrange, automatic), + markercolormap = automatic, + markercolorrange = automatic, markersize = s_theme.markersize, strokecolor = s_theme.strokecolor, strokewidth = s_theme.strokewidth, @@ -27,12 +28,13 @@ $(ATTRIBUTES) ) end +conversion_trait(::Type{<: ScatterLines}) = PointBased() -function plot!(p::Combined{scatterlines, <:NTuple{N, Any}}) where N + +function plot!(p::Plot{scatterlines, <:NTuple{N, Any}}) where N # markercolor is the same as linecolor if left automatic - # RGBColors -> union of all colortypes that `to_color` accepts + returns - real_markercolor = Observable{RGBColors}() + real_markercolor = Observable{Any}() map!(real_markercolor, p.color, p.markercolor) do col, mcol if mcol === automatic return to_color(col) @@ -41,11 +43,22 @@ function plot!(p::Combined{scatterlines, <:NTuple{N, Any}}) where N end end + real_markercolormap = Observable{Any}() + map!(real_markercolormap, p.colormap, p.markercolormap) do col, mcol + mcol === automatic ? col : mcol + end + + real_markercolorrange = Observable{Any}() + map!(real_markercolorrange, p.colorrange, p.markercolorrange) do col, mcol + mcol === automatic ? col : mcol + end + lines!(p, p[1:N]...; color = p.color, linestyle = p.linestyle, linewidth = p.linewidth, colormap = p.colormap, + colorscale = p.colorscale, colorrange = p.colorrange, inspectable = p.inspectable ) @@ -55,8 +68,9 @@ function plot!(p::Combined{scatterlines, <:NTuple{N, Any}}) where N strokewidth = p.strokewidth, marker = p.marker, markersize = p.markersize, - colormap = p.markercolormap, - colorrange = p.markercolorrange, + colormap = real_markercolormap, + colorscale = p.colorscale, + colorrange = real_markercolorrange, inspectable = p.inspectable ) end diff --git a/src/basic_recipes/spy.jl b/src/basic_recipes/spy.jl index 199867dd9fc..eda01af1237 100644 --- a/src/basic_recipes/spy.jl +++ b/src/basic_recipes/spy.jl @@ -18,6 +18,7 @@ $(ATTRIBUTES) marker = automatic, markersize = automatic, colormap = theme(scene, :colormap), + colorscale = identity, colorrange = automatic, framecolor = :black, framesize = 1, @@ -76,7 +77,7 @@ function plot!(p::Spy) p, lift(first, p, xycol), color = lift(last, p, xycol), marker = marker, markersize = markersize, colorrange = p.colorrange, - colormap = p.colormap, inspectable = p.inspectable, visible = p.visible + colormap = p.colormap, colorscale = p.colorscale,inspectable = p.inspectable, visible = p.visible ) lines!(p, rect, color = p.framecolor, linewidth = p.framesize, inspectable = p.inspectable, diff --git a/src/basic_recipes/stem.jl b/src/basic_recipes/stem.jl index 66d40f4b954..682478762e5 100644 --- a/src/basic_recipes/stem.jl +++ b/src/basic_recipes/stem.jl @@ -29,6 +29,7 @@ $(ATTRIBUTES) markersize = theme(scene, :markersize), color = theme(scene, :markercolor), colormap = theme(scene, :colormap), + colorscale = identity, colorrange = automatic, strokecolor = theme(scene, :markerstrokecolor), strokewidth = theme(scene, :markerstrokewidth), @@ -61,6 +62,7 @@ function plot!(s::Stem{<:Tuple{<:AbstractVector{<:Point}}}) linewidth = s.trunkwidth, color = s.trunkcolor, colormap = s.trunkcolormap, + colorscale = s.colorscale, colorrange = s.trunkcolorrange, visible = s.visible, linestyle = s.trunklinestyle, @@ -69,6 +71,7 @@ function plot!(s::Stem{<:Tuple{<:AbstractVector{<:Point}}}) linewidth = s.stemwidth, color = s.stemcolor, colormap = s.stemcolormap, + colorscale = s.colorscale, colorrange = s.stemcolorrange, visible = s.visible, linestyle = s.stemlinestyle, @@ -76,6 +79,7 @@ function plot!(s::Stem{<:Tuple{<:AbstractVector{<:Point}}}) scatter!(s, s[1], color = s.color, colormap = s.colormap, + colorscale = s.colorscale, colorrange = s.colorrange, markersize = s.markersize, marker = s.marker, diff --git a/src/basic_recipes/streamplot.jl b/src/basic_recipes/streamplot.jl index f26b4b9a9e6..6855c9858c5 100644 --- a/src/basic_recipes/streamplot.jl +++ b/src/basic_recipes/streamplot.jl @@ -1,6 +1,6 @@ """ - streamplot(f::function, xinterval, yinterval; kwargs...) + streamplot(f::function, xinterval, yinterval; color = norm, kwargs...) f must either accept `f(::Point)` or `f(x::Number, y::Number)`. f must return a Point2. @@ -10,6 +10,11 @@ Example: v(x::Point2{T}) where T = Point2f(x[2], 4*x[1]) streamplot(v, -2..2, -2..2) ``` + +One can choose the color of the lines by passing a function `color_func(dx::Point)` to the `color` attribute. +By default this is set to `norm`, but can be set to any function or composition of functions. +The `dx` which is passed to `color_func` is the output of `f` at the point being colored. + ## Attributes $(ATTRIBUTES) @@ -17,20 +22,23 @@ $(ATTRIBUTES) See the function `Makie.streamplot_impl` for implementation details. """ @recipe(StreamPlot, f, limits) do scene - merge( - Attributes( - stepsize = 0.01, - gridsize = (32, 32, 32), - maxsteps = 500, - colormap = theme(scene, :colormap), - colorrange = Makie.automatic, - arrow_size = 15, - arrow_head = automatic, - density = 1.0, - quality = 16 - ), - default_theme(scene, Lines) # so that we can theme the lines as needed. + attr = Attributes( + stepsize = 0.01, + gridsize = (32, 32, 32), + maxsteps = 500, + color = norm, + + arrow_size = automatic, + arrow_head = automatic, + density = 1.0, + quality = 16, + + linewidth = theme(scene, :linewidth), + linestyle = nothing, ) + MakieCore.colormap_attributes!(attr, theme(scene, :colormap)) + MakieCore.generic_plot_attributes!(attr) + return attr end function convert_arguments(::Type{<: StreamPlot}, f::Function, xrange, yrange) @@ -73,24 +81,23 @@ Links: [Quasirandom sequences](http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) """ -function streamplot_impl(CallType, f, limits::Rect{N, T}, resolutionND, stepsize, maxsteps=500, dens=1.0) where {N, T} +function streamplot_impl(CallType, f, limits::Rect{N, T}, resolutionND, stepsize, maxsteps=500, dens=1.0, color_func = norm) where {N, T} resolution = to_ndim(Vec{N, Int}, resolutionND, last(resolutionND)) mask = trues(resolution...) # unvisited squares arrow_pos = Point{N, Float32}[] arrow_dir = Vec{N, Float32}[] line_points = Point{N, Float32}[] - colors = Float64[] - line_colors = Float64[] + _cfunc = x-> to_color(color_func(x)) + ColorType = typeof(_cfunc(Point{N,Float32}(0.0))) + line_colors = ColorType[] + colors = ColorType[] dt = Point{N, Float32}(stepsize) mini, maxi = minimum(limits), maximum(limits) r = ntuple(N) do i LinRange(mini[i], maxi[i], resolution[i] + 1) end - apply_f(x0, P) = if P <: Point - f(x0) - else - f(x0...) - end + apply_f(x0, P) = P <: Point ? f(x0) : f(x0...) + # see http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/ ϕ = (MathConstants.φ, 1.324717957244746, 1.2207440846057596)[N] acoeff = ϕ.^(-(1:N)) @@ -112,9 +119,10 @@ function streamplot_impl(CallType, f, limits::Rect{N, T}, resolutionND, stepsize error("Function passed to streamplot must return Point2 or Point3") end pnorm = norm(point) + color = _cfunc(point) push!(arrow_pos, x0) push!(arrow_dir, point ./ pnorm) - push!(colors, pnorm) + push!(colors, color) mask[c] = false n_points += 1 for d in (-1, 1) @@ -122,7 +130,7 @@ function streamplot_impl(CallType, f, limits::Rect{N, T}, resolutionND, stepsize x = x0 ccur = c push!(line_points, Point{N, Float32}(NaN), x) - push!(line_colors, 0.0, pnorm) + push!(line_colors, color, color) while x in limits && n_linepoints < maxsteps point = apply_f(x, CallType) pnorm = norm(point) @@ -142,7 +150,7 @@ function streamplot_impl(CallType, f, limits::Rect{N, T}, resolutionND, stepsize ccur = idx end push!(line_points, x) - push!(line_colors, pnorm) + push!(line_colors, _cfunc(point)) n_linepoints += 1 end end @@ -159,29 +167,34 @@ function streamplot_impl(CallType, f, limits::Rect{N, T}, resolutionND, stepsize end function plot!(p::StreamPlot) - data = lift(p, p.f, p.limits, p.gridsize, p.stepsize, p.maxsteps, p.density) do f, limits, resolution, stepsize, maxsteps, density + data = lift(p, p.f, p.limits, p.gridsize, p.stepsize, p.maxsteps, p.density, p.color) do f, limits, resolution, stepsize, maxsteps, density, color_func P = if applicable(f, Point2f(0)) || applicable(f, Point3f(0)) Point else Number end - streamplot_impl(P, f, limits, resolution, stepsize, maxsteps, density) + streamplot_impl(P, f, limits, resolution, stepsize, maxsteps, density, color_func) end + colormap_args = MakieCore.colormap_attributes(p) + generic_plot_attributes = MakieCore.generic_plot_attributes(p) + lines!( p, - lift(x->x[3], p, data), color = lift(last, p, data), colormap = p.colormap, colorrange = p.colorrange, + lift(x->x[3], p, data), + color = lift(last, p, data), linestyle = p.linestyle, - linewidth = p.linewidth, - inspectable = p.inspectable, - transparency = p.transparency + linewidth = p.linewidth; + colormap_args..., + generic_plot_attributes... ) + N = ndims(p.limits[]) if N == 2 # && scatterplot.markerspace[] == Pixel (default) # Calculate arrow head rotations as angles. To avoid distortions from # (extreme) aspect ratios we need to project to pixel space and renormalize. scene = parent_scene(p) - rotations = lift(p, scene.camera.projectionview, scene.px_area, data) do pv, pxa, data + rotations = lift(p, scene.camera.projectionview, scene.viewport, data) do pv, pxa, data angles = map(data[1], data[2]) do pos, dir pstart = project(scene, pos) pstop = project(scene, pos + dir) @@ -200,12 +213,25 @@ function plot!(p::StreamPlot) rotations = map(x -> x[2], data) end + arrow_size = map(p, p.arrow_size) do arrow_size + if arrow_size === automatic + if N == 3 + return 0.2 * minimum(p.limits[].widths) / minimum(p.gridsize[]) + else + return 15 + end + else + return arrow_size + end + end + scatterfun(N)( p, - lift(first, p, data), markersize = p.arrow_size, - marker=lift((ah, q) -> arrow_head(N, ah, q), p, p.arrow_head, p.quality), - color = lift(x-> x[4], p, data), rotations = rotations, - colormap = p.colormap, colorrange = p.colorrange, - inspectable = p.inspectable, transparency = p.transparency + lift(first, p, data); + markersize=arrow_size, rotations=rotations, + color=lift(x -> x[4], p, data), + marker = lift((ah, q) -> arrow_head(N, ah, q), p, p.arrow_head, p.quality), + colormap_args..., + generic_plot_attributes... ) end diff --git a/src/basic_recipes/text.jl b/src/basic_recipes/text.jl index 7223490ef25..1b42770ede3 100644 --- a/src/basic_recipes/text.jl +++ b/src/basic_recipes/text.jl @@ -1,14 +1,21 @@ +function check_textsize_deprecation(@nospecialize(dictlike)) + if haskey(dictlike, :textsize) + throw(ArgumentError("The attribute `textsize` has been renamed to `fontsize` in Makie v0.19. Please change all occurrences of `textsize` to `fontsize` or revert back to an earlier version.")) + end +end + function plot!(plot::Text) + check_textsize_deprecation(plot) positions = plot[1] # attach a function to any text that calculates the glyph layout and stores it - glyphcollections = Observable(GlyphCollection[]) - linesegs = Observable(Point2f[]) - linewidths = Observable(Float32[]) - linecolors = Observable(RGBAf[]) + glyphcollections = Observable(GlyphCollection[]; ignore_equal_values=true) + linesegs = Observable(Point2f[]; ignore_equal_values=true) + linewidths = Observable(Float32[]; ignore_equal_values=true) + linecolors = Observable(RGBAf[]; ignore_equal_values=true) lineindices = Ref(Int[]) - - onany(plot.text, plot.fontsize, plot.font, plot.fonts, plot.align, - plot.rotation, plot.justification, plot.lineheight, plot.color, + + onany(plot, plot.text, plot.fontsize, plot.font, plot.fonts, plot.align, + plot.rotation, plot.justification, plot.lineheight, plot.calculated_colors, plot.strokecolor, plot.strokewidth, plot.word_wrap_width, plot.offset) do str, ts, f, fs, al, rot, jus, lh, col, scol, swi, www, offs ts = to_fontsize(ts) @@ -23,7 +30,8 @@ function plot!(plot::Text) lwidths = Float32[] lcolors = RGBAf[] lindices = Int[] - function push_args((gc, ls, lw, lc, lindex)) + function push_args(args...) + gc, ls, lw, lc, lindex = _get_glyphcollection_and_linesegments(args...) push!(gcs, gc) append!(lsegs, ls) append!(lwidths, lw) @@ -31,18 +39,15 @@ function plot!(plot::Text) append!(lindices, lindex) return end - func = push_args ∘ _get_glyphcollection_and_linesegments if str isa Vector - # If we have a Vector of strings, Vector arguments are interpreted + # If we have a Vector of strings, Vector arguments are interpreted # as per string. - broadcast_foreach( - func, - str, 1:attr_broadcast_length(str), ts, f, fs, al, rot, jus, lh, col, scol, swi, www, offs + broadcast_foreach(push_args, str, 1:attr_broadcast_length(str), ts, f, fs, al, rot, jus, lh, col, scol, swi, www, offs ) else # Otherwise Vector arguments are interpreted by layout_text/ # glyph_collection as per character. - func(str, 1, ts, f, fs, al, rot, jus, lh, col, scol, swi, www, offs) + push_args(str, 1, ts, f, fs, al, rot, jus, lh, col, scol, swi, www, offs) end glyphcollections[] = gcs linewidths[] = lwidths @@ -51,11 +56,11 @@ function plot!(plot::Text) linesegs[] = lsegs end - linesegs_shifted = Observable(Point2f[]) + linesegs_shifted = Observable(Point2f[]; ignore_equal_values=true) sc = parent_scene(plot) - onany(linesegs, positions, sc.camera.projectionview, sc.px_area, + onany(plot, linesegs, positions, sc.camera.projectionview, sc.viewport, transform_func_obs(sc), get(plot, :space, :data)) do segs, pos, _, _, transf, space pos_transf = plot_to_screen(plot, pos) linesegs_shifted[] = map(segs, lineindices[]) do seg, index @@ -88,6 +93,7 @@ function _get_glyphcollection_and_linesegments(str::AbstractString, index, ts, f gc = layout_text(string(str), ts, f, fs, al, rot, jus, lh, col, scol, swi, www) gc, Point2f[], Float32[], RGBAf[], Int[] end + function _get_glyphcollection_and_linesegments(latexstring::LaTeXString, index, ts, f, fs, al, rot, jus, lh, col, scol, swi, www, offs) tex_elements, glyphcollections, offset = texelems_and_glyph_collection(latexstring, ts, al[1], al[2], rot, col, scol, swi, www) @@ -141,7 +147,7 @@ function plot!(plot::Text{<:Tuple{<:AbstractArray{<:AbstractString}}}) end # overload text plotting for a vector of tuples of a string and a point each -function plot!(plot::Text{<:Tuple{<:AbstractArray{<:Tuple{<:Any, <:Point}}}}) +function plot!(plot::Text{<:Tuple{<:AbstractArray{<:Tuple{<:Any, <:Point}}}}) strings_and_positions = plot[1] strings = Observable{Vector{Any}}(first.(strings_and_positions[])) @@ -156,12 +162,17 @@ function plot!(plot::Text{<:Tuple{<:AbstractArray{<:Tuple{<:Any, <:Point}}}}) text!(plot, positions; text = strings, attrs...) # update both text and positions together - on(strings_and_positions) do str_pos + on(plot, strings_and_positions) do str_pos strs = first.(str_pos) poss = to_ndim.(Ref(Point3f), last.(str_pos), 0) - strings.val != strs && (strings[] = strs) - positions.val != poss && (positions[] = poss) + strings_unequal = strings.val != strs + pos_unequal = positions.val != poss + strings_unequal && (strings.val = strs) + pos_unequal && (positions.val = poss) + # Check for equality very imortant, otherwise we get an infinite loop + strings_unequal && notify(strings) + pos_unequal && notify(positions) return end @@ -230,21 +241,8 @@ function texelems_and_glyph_collection(str::LaTeXString, fontscale_px, halign, v end end - xshift = if halign === :center - width(bb) ./ 2 - elseif halign === :left - minimum(bb)[1] - elseif halign === :right - maximum(bb)[1] - end - - yshift = if valign === :center - maximum(bb)[2] - (height(bb) / 2) - elseif valign === :top - maximum(bb)[2] - else - minimum(bb)[2] - end + xshift = get_xshift(minimum(bb)[1], maximum(bb)[1], halign) + yshift = get_yshift(minimum(bb)[2], maximum(bb)[2], valign, default=0f0) shift = Vec3f(xshift, yshift, 0) positions = basepositions .- Ref(shift) @@ -321,6 +319,30 @@ struct GlyphInfo strokewidth::Float32 end +# Copy constructor, to overwrite a field +function GlyphInfo(gi::GlyphInfo; + glyph=gi.glyph, + font=gi.font, + origin=gi.origin, + extent=gi.extent, + size=gi.size, + rotation=gi.rotation, + color=gi.color, + strokecolor=gi.strokecolor, + strokewidth=gi.strokewidth) + + return GlyphInfo(glyph, + font, + origin, + extent, + size, + rotation, + color, + strokecolor, + strokewidth) +end + + function GlyphCollection(v::Vector{GlyphInfo}) GlyphCollection( [i.glyph for i in v], @@ -343,7 +365,7 @@ function layout_text(rt::RichText, ts, f, fset, al, rot, jus, lh, col) stack = [GlyphState(0, 0, Vec2f(ts), _f, to_color(col))] lines = [GlyphInfo[]] - + process_rt_node!(stack, lines, rt, fset) apply_lineheight!(lines, lh) @@ -354,62 +376,59 @@ function layout_text(rt::RichText, ts, f, fset, al, rot, jus, lh, col) gc.origins .= Ref(quat) .* gc.origins @assert gc.rotations.sv isa Vector # should always be a vector because that's how the glyphcollection is created gc.rotations.sv .= Ref(quat) .* gc.rotations.sv - gc + return gc end function apply_lineheight!(lines, lh) for (i, line) in enumerate(lines) for j in eachindex(line) l = line[j] - l = Setfield.@set l.origin[2] -= (i-1) * 20 # TODO: Lineheight + ox, oy = l.origin + # TODO: Lineheight + l = GlyphInfo(l; origin=Point2f(ox, oy - (i - 1) * 20)) line[j] = l end end return end -function apply_alignment_and_justification!(lines, ju, al) - max_xs = map(lines) do line - maximum(line, init = 0f0) do ginfo - ginfo.origin[1] + ginfo.extent.hadvance * ginfo.size[1] - end +function max_x_advance(glyph_infos::Vector{GlyphInfo})::Float32 + return maximum(glyph_infos; init=0.0f0) do ginfo + ginfo.origin[1] + ginfo.extent.hadvance * ginfo.size[1] end - max_x = maximum(max_xs) +end - top_y = maximum(lines[1]) do ginfo - ginfo.origin[2] + ginfo.extent.ascender * ginfo.size[2] - end - bottom_y = minimum(lines[end]) do ginfo - ginfo.origin[2] + ginfo.extent.descender * ginfo.size[2] +function max_y_ascender(glyph_infos::Vector{GlyphInfo})::Float32 + return maximum(glyph_infos) do ginfo + return ginfo.origin[2] + ginfo.extent.ascender * ginfo.size[2] end +end - al_offset_x = if al[1] === :center - max_x / 2 - elseif al[1] === :left - 0f0 - elseif al[1] === :right - max_x - else - 0f0 +function min_y_descender(glyph_infos::Vector{GlyphInfo})::Float32 + return minimum(glyph_infos) do ginfo + return ginfo.origin[2] + ginfo.extent.descender * ginfo.size[2] end +end - al_offset_y = if al[2] === :center - 0.5 * (top_y + bottom_y) - elseif al[2] === :bottom - bottom_y - elseif al[2] === :top - top_y - else - 0f0 - end +function apply_alignment_and_justification!(lines, ju, al) + + max_xs = map(max_x_advance, lines) + max_x = maximum(max_xs) + + top_y = max_y_ascender(lines[1]) + bottom_y = min_y_descender(lines[end]) + + al_offset_x = get_xshift(0f0, max_x, al[1]; default=0f0) + al_offset_y = get_yshift(bottom_y, top_y, al[2]; default=0f0) fju = float_justification(ju, al) - + for (i, line) in enumerate(lines) ju_offset = fju * (max_x - max_xs[i]) for j in eachindex(line) l = line[j] - l = Setfield.@set l.origin -= Point2f(al_offset_x - ju_offset, al_offset_y) + o = l.origin + l = GlyphInfo(l; origin = o .- Point2f(al_offset_x - ju_offset, al_offset_y)) line[j] = l end end @@ -419,23 +438,9 @@ end function float_justification(ju, al)::Float32 halign = al[1] float_justification = if ju === automatic - if halign === :left || halign == 0 - 0.0f0 - elseif halign === :right || halign == 1 - 1.0f0 - elseif halign === :center || halign == 0.5 - 0.5f0 - else - 0.5f0 - end - elseif ju === :left - 0.0f0 - elseif ju === :right - 1.0f0 - elseif ju === :center - 0.5f0 + get_xshift(0f0, 1f0, halign) else - Float32(ju) + get_xshift(0f0, 1f0, ju; default=ju) # errors if wrong symbol is used end end @@ -463,12 +468,13 @@ function process_rt_node!(stack, lines, s::String, _) x = 0 push!(lines, GlyphInfo[]) else - gi = FreeTypeAbstraction.glyph_index(gs.font, char) - gext = GlyphExtent(gs.font, char) + bestfont = find_font_for_char(char, gs.font) + gi = FreeTypeAbstraction.glyph_index(bestfont, char) + gext = GlyphExtent(bestfont, char) ori = Point2f(x, y) push!(lines[end], GlyphInfo( gi, - gs.font, + bestfont, ori, gext, gs.size, @@ -533,3 +539,21 @@ function new_glyphstate(gs::GlyphState, rt::RichText, val::Val{:sub}, fonts) end iswhitespace(r::RichText) = iswhitespace(String(r)) + +function get_xshift(lb, ub, align; default=0.5f0) + if align isa Symbol + align = align === :left ? 0.0f0 : + align === :center ? 0.5f0 : + align === :right ? 1.0f0 : default + end + lb * (1-align) + ub * align |> Float32 +end + +function get_yshift(lb, ub, align; default=0.5f0) + if align isa Symbol + align = align === :bottom ? 0.0f0 : + align === :center ? 0.5f0 : + align === :top ? 1.0f0 : default + end + lb * (1-align) + ub * align |> Float32 +end diff --git a/src/basic_recipes/tooltip.jl b/src/basic_recipes/tooltip.jl index ae0a516f04c..b94b557a74b 100644 --- a/src/basic_recipes/tooltip.jl +++ b/src/basic_recipes/tooltip.jl @@ -36,19 +36,20 @@ Creates a tooltip pointing at `position` displaying the given `string` """ @recipe(Tooltip, position) do scene Attributes(; - # General - text = "", + # General + text = "", offset = 10, placement = :above, align = 0.5, - xautolimits = false, - yautolimits = false, + xautolimits = false, + yautolimits = false, zautolimits = false, overdraw = false, depth_shift = 0f0, transparency = false, visible = true, inspectable = false, + space = :data, # Text textpadding = (4, 4, 4, 4), # LRBT @@ -62,7 +63,7 @@ Creates a tooltip pointing at `position` displaying the given `string` # Background backgroundcolor = :white, triangle_size = 10, - + # Outline outline_color = :black, outline_linewidth = 2f0, @@ -81,15 +82,21 @@ end function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) # TODO align - scene = parent_scene(p) - px_pos = map(scene.camera.projectionview, scene.camera.resolution, p[1]) do _, _, p - project(scene, p) + px_pos = map( + p, p[1], scene.camera.projectionview, p.model, transform_func(p), + p.space, scene.viewport) do pos, _, model, tf, space, viewport + + # Adjusted from error_and_rangebars + spvm = clip_to_space(scene.camera, :pixel) * space_to_clip(scene.camera, space) * model + transformed = apply_transform(tf, pos, space) + p4d = spvm * to_ndim(Point4f, to_ndim(Point3f, transformed, 0), 1) + return Point3f(p4d) / p4d[4] end # Text - textpadding = map(p.textpadding) do pad + textpadding = map(p, p.textpadding) do pad if pad isa Real return (pad, pad, pad, pad) elseif length(pad) == 4 @@ -100,10 +107,10 @@ function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) end end - text_offset = map(p.offset, textpadding, p.triangle_size, p.placement, p.align) do o, pad, ts, placement, align + text_offset = map(p, p.offset, textpadding, p.triangle_size, p.placement, p.align) do o, pad, ts, placement, align l, r, b, t = pad - if placement === :left + if placement === :left return Vec2f(-o - r - ts, b - align * (b + t)) elseif placement === :right return Vec2f( o + l + ts, b - align * (b + t)) @@ -117,8 +124,8 @@ function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) end end - text_align = map(p.placement, p.align) do placement, align - if placement === :left + text_align = map(p, p.placement, p.align) do placement, align + if placement === :left return (1.0, align) elseif placement === :right return (0.0, align) @@ -139,27 +146,27 @@ function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) strokewidth = p.strokewidth, strokecolor = p.strokecolor, transparency = p.transparency, visible = p.visible, overdraw = p.overdraw, depth_shift = p.depth_shift, - inspectable = p.inspectable, space = :pixel + inspectable = p.inspectable, space = :pixel, transformation = Transformation() ) translate!(tp, 0, 0, 1) # TODO react to glyphcollection instead bbox = map( - px_pos, p.text, text_align, text_offset, textpadding, p.align + p, px_pos, p.text, text_align, text_offset, textpadding, p.align ) do p, s, _, o, pad, align - bb = Rect2f(boundingbox(tp)) + o + bb = boundingbox(tp) + to_ndim(Vec3f, o, 0) l, r, b, t = pad - return Rect2f(origin(bb) .- (l, b), widths(bb) .+ (l+r, b+t)) + return Rect3f(origin(bb) .- (l, b, 0), widths(bb) .+ (l+r, b+t, 0)) end # Text background mesh mesh!( - p, bbox, shading = false, space = :pixel, + p, bbox, shading = NoShading, space = :pixel, color = p.backgroundcolor, fxaa = false, - transparency = p.transparency, visible = p.visible, + transparency = p.transparency, visible = p.visible, overdraw = p.overdraw, depth_shift = p.depth_shift, - inspectable = p.inspectable + inspectable = p.inspectable, transformation = Transformation() ) # Triangle mesh @@ -170,31 +177,31 @@ function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) ) mp = mesh!( - p, triangle, shading = false, space = :pixel, - color = p.backgroundcolor, + p, triangle, shading = NoShading, space = :pixel, + color = p.backgroundcolor, transparency = p.transparency, visible = p.visible, overdraw = p.overdraw, depth_shift = p.depth_shift, - inspectable = p.inspectable + inspectable = p.inspectable, transformation = Transformation() ) - onany(bbox, p.triangle_size, p.placement, p.align) do bb, s, placement, align + onany(p, bbox, p.triangle_size, p.placement, p.align) do bb, s, placement, align o = origin(bb); w = widths(bb) scale!(mp, s, s, s) - - if placement === :left - translate!(mp, Vec3f(o[1] + w[1], o[2] + align * w[2], 0)) + + if placement === :left + translate!(mp, Vec3f(o[1] + w[1], o[2] + align * w[2], o[3])) rotate!(mp, qrotation(Vec3f(0,0,1), 0.5pi)) elseif placement === :right translate!(mp, Vec3f(o[1], o[2] + align * w[2], 0)) rotate!(mp, qrotation(Vec3f(0,0,1), -0.5pi)) elseif placement in (:below, :down, :bottom) - translate!(mp, Vec3f(o[1] + align * w[1], o[2] + w[2], 0)) + translate!(mp, Vec3f(o[1] + align * w[1], o[2] + w[2], o[3])) rotate!(mp, Quaternionf(0,0,1,0)) # pi elseif placement in (:above, :up, :top) - translate!(mp, Vec3f(o[1] + align * w[1], o[2], 0)) + translate!(mp, Vec3f(o[1] + align * w[1], o[2], o[3])) rotate!(mp, Quaternionf(0,0,0,1)) # 0 else @error "Tooltip placement $placement invalid. Assuming :above" - translate!(mp, Vec3f(o[1] + align * w[1], o[2], 0)) + translate!(mp, Vec3f(o[1] + align * w[1], o[2], o[3])) rotate!(mp, Quaternionf(0,0,0,1)) end return @@ -202,8 +209,8 @@ function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) # Outline - outline = map(bbox, p.triangle_size, p.placement, p.align) do bb, s, placement, align - l, b = origin(bb); w, h = widths(bb) + outline = map(p, bbox, p.triangle_size, p.placement, p.align) do bb, s, placement, align + l, b, z = origin(bb); w, h, _ = widths(bb) r, t = (l, b) .+ (w, h) # We start/end at half width/height here to avoid corners like this: @@ -212,59 +219,59 @@ function plot!(p::Tooltip{<:Tuple{<:VecTypes}}) # | ____ # | | - shift = if placement === :left + shift = if placement === :left Vec2f[ - (l, b + 0.5h), (l, t), (r, t), - (r, b + align * h + 0.5s), - (r + s, b + align * h), + (l, b + 0.5h), (l, t), (r, t), + (r, b + align * h + 0.5s), + (r + s, b + align * h), (r, b + align * h - 0.5s), (r, b), (l, b), (l, b + 0.5h) ] elseif placement === :right Vec2f[ - (l + 0.5w, b), (l, b), - (l, b + align * h - 0.5s), - (l-s, b + align * h), + (l + 0.5w, b), (l, b), + (l, b + align * h - 0.5s), + (l-s, b + align * h), (l, b + align * h + 0.5s), (l, t), (r, t), (r, b), (l + 0.5w, b) ] elseif placement in (:below, :down, :bottom) Vec2f[ - (l, b + 0.5h), (l, t), - (l + align * w - 0.5s, t), - (l + align * w, t+s), - (l + align * w + 0.5s, t), + (l, b + 0.5h), (l, t), + (l + align * w - 0.5s, t), + (l + align * w, t+s), + (l + align * w + 0.5s, t), (r, t), (r, b), (l, b), (l, b + 0.5h) ] elseif placement in (:above, :up, :top) Vec2f[ - (l, b + 0.5h), (l, t), (r, t), (r, b), - (l + align * w + 0.5s, b), - (l + align * w, b-s), - (l + align * w - 0.5s, b), + (l, b + 0.5h), (l, t), (r, t), (r, b), + (l + align * w + 0.5s, b), + (l + align * w, b-s), + (l + align * w - 0.5s, b), (l, b), (l, b + 0.5h) ] else @error "Tooltip placement $placement invalid. Assuming :above" Vec2f[ - (l, b + 0.5h), (l, t), (r, t), (r, b), - (l + align * w + 0.5s, b), - (l + align * w, b-s), - (l + align * w - 0.5s, b), + (l, b + 0.5h), (l, t), (r, t), (r, b), + (l + align * w + 0.5s, b), + (l + align * w, b-s), + (l + align * w - 0.5s, b), (l, b), (l, b + 0.5h) ] end - return shift + return to_ndim.(Vec3f, shift, z) end lines!( - p, outline, - color = p.outline_color, space = :pixel, + p, outline, + color = p.outline_color, space = :pixel, linewidth = p.outline_linewidth, linestyle = p.outline_linestyle, transparency = p.transparency, visible = p.visible, overdraw = p.overdraw, depth_shift = p.depth_shift, - inspectable = p.inspectable + inspectable = p.inspectable, transformation = Transformation() ) notify(p[1]) diff --git a/src/basic_recipes/tricontourf.jl b/src/basic_recipes/tricontourf.jl index b4f3808a6b9..e711345735d 100644 --- a/src/basic_recipes/tricontourf.jl +++ b/src/basic_recipes/tricontourf.jl @@ -4,7 +4,7 @@ struct DelaunayTriangulation end tricontourf(triangles::Triangulation, zs; kwargs...) tricontourf(xs, ys, zs; kwargs...) -Plots a filled tricontour of the height information in `zs` at the horizontal positions `xs` and +Plots a filled tricontour of the height information in `zs` at the horizontal positions `xs` and vertical positions `ys`. A `Triangulation` from DelaunayTriangulation.jl can also be provided instead of `xs` and `ys` for specifying the triangles, otherwise an unconstrained triangulation of `xs` and `ys` is computed. @@ -29,6 +29,7 @@ for specifying the triangles, otherwise an unconstrained triangulation of `xs` a - `model::Makie.Mat4f` sets a model matrix for the plot. This replaces adjustments made with `translate!`, `rotate!` and `scale!`. - `color` sets the color of the plot. It can be given as a named color `Symbol` or a `Colors.Colorant`. Transparency can be included either directly as an alpha value in the `Colorant` or as an additional float in a tuple `(color, alpha)`. The color can also be set for each scattered marker by passing a `Vector` of colors or be used to index the `colormap` by passing a `Real` number or `Vector{<: Real}`. - `colormap::Union{Symbol, Vector{<:Colorant}} = :viridis` sets the colormap from which the band colors are sampled. +- `colorscale::Function = identity` color transform function. ## Attributes $(ATTRIBUTES) @@ -38,6 +39,7 @@ $(ATTRIBUTES) levels = 10, mode = :normal, colormap = theme(scene, :colormap), + colorscale = identity, extendlow = nothing, extendhigh = nothing, nan_color = :transparent, @@ -52,16 +54,16 @@ function Makie.used_attributes(::Type{<:Tricontourf}, ::AbstractVector{<:Real}, return (:triangulation,) end -function Makie.convert_arguments(::Type{<:Tricontourf}, x::AbstractVector{<:Real}, y::AbstractVector{<:Real}, z::AbstractVector{<:Real}; +function Makie.convert_arguments(::Type{<:Tricontourf}, x::AbstractVector{<:Real}, y::AbstractVector{<:Real}, z::AbstractVector{<:Real}; triangulation=DelaunayTriangulation()) z = elconvert(Float32, z) points = [x'; y'] if triangulation isa DelaunayTriangulation tri = DelTri.triangulate(points) elseif !(triangulation isa DelTri.Triangulation) - # Wrap user's provided triangulation into a Triangulation. Their triangulation must be such that DelTri.add_triangle! is defined. - if typeof(triangulation) <: AbstractMatrix{<:Int} && size(triangulation, 1) != 3 - triangulation = triangulation' + # Wrap user's provided triangulation into a Triangulation. Their triangulation must be such that DelTri.add_triangle! is defined. + if typeof(triangulation) <: AbstractMatrix{<:Int} && size(triangulation, 1) != 3 + triangulation = triangulation' end tri = DelTri.Triangulation(points) triangles = DelTri.get_triangles(tri) @@ -188,6 +190,7 @@ function Makie.plot!(c::Tricontourf{<:Tuple{<:DelTri.Triangulation, <:AbstractVe poly!(c, polys, colormap = c._computed_colormap, + colorscale = c.colorscale, colorrange = colorrange, highclip = highcolor, lowclip = lowcolor, @@ -195,7 +198,6 @@ function Makie.plot!(c::Tricontourf{<:Tuple{<:DelTri.Triangulation, <:AbstractVe color = colors, strokewidth = 0, strokecolor = :transparent, - shading = false, inspectable = c.inspectable, transparency = c.transparency ) diff --git a/src/basic_recipes/triplot.jl b/src/basic_recipes/triplot.jl new file mode 100644 index 00000000000..917e3ff4265 --- /dev/null +++ b/src/basic_recipes/triplot.jl @@ -0,0 +1,255 @@ +""" + triplot(x, y; kwargs...) + triplot(positions; kwargs...) + triplot(triangles::Triangulation; kwargs...) + +Plots a triangulation based on the provided position or `Triangulation` from DelaunayTriangulation.jl. + +## Attributes + +- `show_points = false` determines whether to plot the individual points. Note that this will only plot points included in the triangulation. +- `show_convex_hull = false` determines whether to plot the convex hull. +- `show_ghost_edges = false` determines whether to plot the ghost edges. +- `show_constrained_edges = false` determines whether to plot the constrained edges. +- `recompute_centers = false` determines whether to recompute the representative points for the ghost edge orientation. Note that this will mutate `tri.representative_point_list` directly. + +- `markersize = 12` sets the size of the points. +- `marker = :circle` sets the shape of the points. +- `markercolor = :black` sets the color of the points. +- `strokecolor = :black` sets the color of triangle edges. +- `strokewidth = 1` sets the linewidth of triangle edges. +- `linestyle = :solid` sets the linestyle of triangle edges. +- `triangle_color = (:white, 0.0)` sets the color of the triangles. + +- `convex_hull_color = :red` sets the color of the convex hull. +- `convex_hull_linestyle = :dash` sets the linestyle of the convex hull. +- `convex_hull_linewidth = 1` sets the width of the convex hull. + +- `ghost_edge_color = :blue` sets the color of the ghost edges. +- `ghost_edge_linestyle = :solid` sets the linestyle of the ghost edges. +- `ghost_edge_linewidth = 1` sets the width of the ghost edges. +- `ghost_edge_extension_factor = 0.1` sets the extension factor for the rectangle that the exterior ghost edges are extended onto. +- `bounding_box::Union{Automatic, Rect2, Tuple} = automatic`: Sets the bounding box for truncating ghost edges which can be a `Rect2` (or `BBox`) or a tuple of the form `(xmin, xmax, ymin, ymax)`. By default, the rectangle will be given by `[a - eΔx, b + eΔx] × [c - eΔy, d + eΔy]` where `e` is the `ghost_edge_extension_factor`, `Δx = b - a` and `Δy = d - c` are the lengths of the sides of the rectangle, and `[a, b] × [c, d]` is the bounding box of the points in the triangulation. + +- `constrained_edge_color = :magenta` sets the color of the constrained edges. +- `constrained_edge_linestyle = :solid` sets the linestyle of the constrained edges. +- `constrained_edge_linewidth = 1` sets the width of the constrained edges. +""" +@recipe(Triplot, triangles) do scene + sc = default_theme(scene, Scatter) + return Attributes(; + # Toggles + show_points=false, + show_convex_hull=false, + show_ghost_edges=false, + show_constrained_edges=false, + recompute_centers=false, + + # Mesh settings + markersize=theme(scene, :markersize), + marker=theme(scene, :marker), + markercolor=sc.color, + strokecolor=theme(scene, :patchstrokecolor), + strokewidth=1, + linestyle=:solid, + triangle_color=(:white, 0.0), + + # Convex hull settings + convex_hull_color=:red, + convex_hull_linestyle=:dash, + convex_hull_linewidth=theme(scene, :linewidth), + + # Ghost edge settings + ghost_edge_color=:blue, + ghost_edge_linestyle=theme(scene, :linestyle), + ghost_edge_linewidth=theme(scene, :linewidth), + ghost_edge_extension_factor=0.1, + bounding_box=automatic, + + # Constrained edge settings + constrained_edge_color=:magenta, + constrained_edge_linestyle=theme(scene, :linestyle), + constrained_edge_linewidth=theme(scene, :linewidth)) +end + +function get_all_triangulation_points!(points, tri) + empty!(points) + sizehint!(points, DelTri.num_points(tri)) + for p in DelTri.each_point(tri) + x, y = DelTri.getxy(p) + push!(points, Point2f(x, y)) + end + return points +end + +function get_present_triangulation_points!(points, tri) + empty!(points) + sizehint!(points, DelTri.num_solid_vertices(tri)) + for i in DelTri.each_solid_vertex(tri) + p = DelTri.get_point(tri, i) + x, y = DelTri.getxy(p) + push!(points, Point2f(x, y)) + end + return points +end + +function get_triangulation_triangles!(triangles, tri) + empty!(triangles) + sizehint!(triangles, DelTri.num_solid_triangles(tri)) + for T in DelTri.each_solid_triangle(tri) + i, j, k = DelTri.indices(T) + push!(triangles, TriangleFace(i, j, k)) + end + return triangles +end + +function get_triangulation_ghost_edges!(ghost_edges, extent, tri, bounding_box) + @assert extent > 0.0 "The ghost_edge_extension_factor must be positive." + empty!(ghost_edges) + sizehint!(ghost_edges, 2DelTri.num_ghost_edges(tri)) + if bounding_box === automatic + if DelTri.has_boundary_nodes(tri) + xmin, xmax, ymin, ymax = DelTri.polygon_bounds(DelTri.get_points(tri), + DelTri.get_boundary_nodes(tri), + Val(true)) + else + xmin, xmax, ymin, ymax = DelTri.polygon_bounds(DelTri.get_points(tri), + DelTri.get_convex_hull_indices(tri), + Val(true)) + end + Δx = xmax - xmin + Δy = ymax - ymin + a, b, c, d = (xmin - extent * Δx, xmax + extent * Δx, ymin - extent * Δy, ymax + extent * Δy) + elseif bounding_box isa Rect2 + a, c = minimum(bounding_box) + b, d = maximum(bounding_box) + else + a, b, c, d = bounding_box + end + a, b, c, d = map(Float64, (a, b, c, d)) + @assert a < b && c < d "Bounding box must be of the form (xmin, xmax, ymin, ymax)." + for e in DelTri.each_ghost_edge(tri) + u, v = DelTri.edge_indices(e) + if DelTri.is_boundary_index(v) + u, v = v, u # Make sure that u is the boundary index + end + curve_index = DelTri.get_curve_index(tri, u) + representative_coordinates = DelTri.get_representative_point_coordinates(tri, curve_index) + rx, ry = DelTri.getxy(representative_coordinates) + @assert a ≤ rx ≤ b && c ≤ ry ≤ d "The representative point is not in the bounding box." + p = DelTri.get_point(tri, v) + px, py = DelTri.getxy(p) + if !DelTri.is_positively_oriented(tri, curve_index) + ex, ey = rx, ry + else + e = DelTri.intersection_of_ray_with_bounding_box(representative_coordinates, p, a, b, c, d) + ex, ey = DelTri.getxy(e) + end + push!(ghost_edges, Point2f(px, py), Point2f(ex, ey)) + end + return ghost_edges +end + +function get_triangulation_convex_hull!(convex_hull, tri) + idx = DelTri.get_convex_hull_indices(tri) + empty!(convex_hull) + sizehint!(convex_hull, length(idx)) + for i in idx + p = DelTri.get_point(tri, i) + x, y = DelTri.getxy(p) + push!(convex_hull, Point2f(x, y)) + end + return convex_hull +end + +function get_triangulation_constrained_edges!(constrained_edges, tri) + empty!(constrained_edges) + sizehint!(constrained_edges, DelTri.num_edges(DelTri.get_all_constrained_edges(tri))) + for e in DelTri.each_constrained_edge(tri) + u, v = DelTri.edge_indices(e) + p = DelTri.get_point(tri, u) + q = DelTri.get_point(tri, v) + px, py = DelTri.getxy(p) + qx, qy = DelTri.getxy(q) + push!(constrained_edges, Point2f(px, py), Point2f(qx, qy)) + end + return constrained_edges +end + +Makie.convert_arguments(::Type{<:Triplot}, ps) = convert_arguments(PointBased(), ps) +Makie.convert_arguments(::Type{<:Triplot}, xs, ys) = convert_arguments(PointBased(), xs, ys) +Makie.convert_arguments(::Type{<:Triplot}, x::DelTri.Triangulation) = (x,) + +function Makie.plot!(p::Triplot{<:Tuple{<:Vector{<:Point}}}) + attr = copy(p.attributes) + + # Handle transform_func early so tessellation is in cartesian space. + tri = map(p, p.transformation.transform_func, p[1]) do tf, ps + transformed = Makie.apply_transform(tf, ps) + return DelTri.triangulate(transformed) + end + + attr[:transformation] = Transformation(p.transformation; transform_func=identity) + triplot!(p, attr, tri) + return +end + +function Makie.plot!(p::Triplot{<:Tuple{<:DelTri.Triangulation}}) + points_2f = Observable(Point2f[]) + present_points_2f = Observable(Point2f[]) # Points might not be in the triangulation yet, so points_2f is not what we want for scatter + triangles_3f = Observable(Makie.TriangleFace{Int}[]) + ghost_edges_2f = Observable(Point2f[]) + convex_hull_2f = Observable(Point2f[]) + constrained_edges_2f = Observable(Point2f[]) + + function update_plot(tri) + p.recompute_centers[] && DelTri.compute_representative_points!(tri) + get_all_triangulation_points!(points_2f[], tri) + + p.show_points[] && get_present_triangulation_points!(present_points_2f[], tri) + get_triangulation_triangles!(triangles_3f[], tri) + + if p.show_ghost_edges[] + ge = ghost_edges_2f[] + extent = p.ghost_edge_extension_factor[] + bbox = p.bounding_box[] + get_triangulation_ghost_edges!(ge, extent, tri, bbox) + end + + p.show_convex_hull[] && get_triangulation_convex_hull!(convex_hull_2f[], tri) + p.show_constrained_edges[] && get_triangulation_constrained_edges!(constrained_edges_2f[], tri) + + foreach(notify, + (points_2f, present_points_2f, triangles_3f, ghost_edges_2f, convex_hull_2f, + constrained_edges_2f)) + return nothing + end + onany(update_plot, p, p[1]) + update_plot(p[1][]) + + poly!(p, points_2f, triangles_3f; strokewidth=p.strokewidth, strokecolor=p.strokecolor, + color=p.triangle_color) + linesegments!(p, ghost_edges_2f; color=p.ghost_edge_color, linewidth=p.ghost_edge_linewidth, + linestyle=p.ghost_edge_linestyle, xautolimits=false, yautolimits=false) + lines!(p, convex_hull_2f; color=p.convex_hull_color, linewidth=p.convex_hull_linewidth, + linestyle=p.convex_hull_linestyle, depth_shift=-1.0f-5) + linesegments!(p, constrained_edges_2f; color=p.constrained_edge_color, depth_shift=-2.0f-5, + linewidth=p.constrained_edge_linewidth, linestyle=p.constrained_edge_linestyle) + scatter!(p, present_points_2f; markersize=p.markersize, color=p.markercolor, + strokecolor=p.strokecolor, marker=p.marker, visible=p.show_points, depth_shift=-3.0f-5) + return p +end + + +function data_limits(p::Triplot{<:Tuple{<:Vector{<:Point}}}) + if transform_func(p) isa Polar + # Because the Polar transform is handled explicitly we cannot rely + # on the default data_limits. (data limits are pre transform) + iter = (to_ndim(Point3f, p, 0f0) for p in p.converted[1][]) + limits_from_transformed_points(iter) + else + # First component is either another Voronoiplot or a poly plot. Both + # cases span the full limits of the plot + data_limits(p.plots[1]) + end +end \ No newline at end of file diff --git a/src/basic_recipes/voronoiplot.jl b/src/basic_recipes/voronoiplot.jl new file mode 100644 index 00000000000..7ce3d6f87b2 --- /dev/null +++ b/src/basic_recipes/voronoiplot.jl @@ -0,0 +1,237 @@ +""" + voronoiplot(x, y, values; kwargs...) + voronoiplot(values; kwargs...) + voronoiplot(x, y; kwargs...) + voronoiplot(positions; kwargs...) + voronoiplot(vorn::VoronoiTessellation; kwargs...) + +Generates and plots a Voronoi tessalation from `heatmap`- or point-like data. +The tessellation can also be passed directly as a `VoronoiTessellation` from +DelaunayTriangulation.jl. + +## Attributes + +- `show_generators = true` determines whether to plot the individual generators. + +- `markersize = 12` sets the size of the points. +- `marker = :circle` sets the shape of the points. +- `markercolor = :black` sets the color of the points. + +- `strokecolor = :black` sets the strokecolor of the polygons. +- `strokewidth = 1` sets the width of the polygon stroke. +- `color = automatic` sets the color of the polygons. If `automatic`, the polygons will be individually colored according to the colormap. +- `unbounded_edge_extension_factor = 0.1` sets the extension factor for the unbounded edges, used in `DelaunayTriangulation.polygon_bounds`. +- `clip::Union{Automatic, Rect2, Circle, Tuple} = automatic` sets the clipping area for the generated polygons which can be a `Rect2` (or `BBox`), `Tuple` with entries `(xmin, xmax, ymin, ymax)` or as a `Circle`. Anything outside the specified area will be removed. If the `clip` is not set it is automatically determined using `unbounded_edge_extension_factor` as a `Rect`. + +$(Base.Docs.doc(MakieCore.colormap_attributes!)) +""" +@recipe(Voronoiplot, vorn) do scene + th = default_theme(scene, Mesh) + sc = default_theme(scene, Scatter) + attr = Attributes(; + # Toggles + show_generators=true, + smooth=false, + + # Point settings + markersize=sc.markersize, + marker=sc.marker, + markercolor=sc.color, + + # Polygon settings + strokecolor=theme(scene, :patchstrokecolor), + strokewidth=1.0, + color=automatic, + unbounded_edge_extension_factor=0.1, + clip=automatic) + MakieCore.colormap_attributes!(attr, theme(scene, :colormap)) + return attr +end + +function _clip_polygon(poly::Polygon, circle::Circle) + # Sutherland-Hodgman adjusted + @assert isempty(poly.interiors) "Polygon must not have holes for clipping." + + function intersection(A, B, circle) + CA = A - origin(circle) + AB = B - A + a = dot(AB, AB) # > 0 + b = 2 * dot(CA, AB) # > 0 + c = dot(CA, CA) - radius(circle) * radius(circle) + t = (sqrt(b * b - 4 * a * c) - b) / (2a) # only solution > 0 matters + return A + AB * t + end + + input = Point2f.(first.(poly.exterior)) + output = sizehint!(Point2f[], length(input)) + + for i in eachindex(input) + p1 = input[mod1(i - 1, end)] + p2 = input[i] + + Cp1 = p1 - origin(circle) + Cp2 = p2 - origin(circle) + r2 = radius(circle) * radius(circle) + if dot(Cp1, Cp1) < r2 # p1 inside + if dot(Cp2, Cp2) > r2 # p2 outside + p = intersection(p1, p2, circle) + push!(output, p) + else # both inside + push!(output, p2) + end + elseif dot(Cp2, Cp2) < r2 # p1 outside, p2 inside + p = intersection(p2, p1, circle) + push!(output, p, p2) + end + end + + return Polygon(output) +end +_clip_polygon(poly::Polygon, ::Any) = poly + +function get_voronoi_tiles!(generators, polygons, vorn, bbox) + function voronoi_bbox(c::Circle) + o = Float64.(origin(c)) + r = Float64(radius(c)) + return (o[1] - r, o[1] + r, o[2] - r, o[2] + r) + end + function voronoi_bbox(r::Rect2) + mini = Float64.(minimum(r)) + maxi = Float64.(maximum(r)) + return (mini[1], maxi[1], mini[2], maxi[2]) + end + voronoi_bbox(t::Tuple) = Float64.(t) + voronoi_bbox(::Nothing) = nothing + + empty!(generators) + empty!(polygons) + sizehint!(generators, DelTri.num_generators(vorn)) + sizehint!(polygons, DelTri.num_polygons(vorn)) + + for i in DelTri.each_generator(vorn) + polygon_coords = DelTri.get_polygon_coordinates(vorn, i, voronoi_bbox(bbox)) + polygon_coords_2f = map(polygon_coords) do coords + return Point2f(DelTri.getxy(coords)) + end + push!(polygons, _clip_polygon(Polygon(polygon_coords_2f), bbox)) + gp = Point2f(DelTri.getxy(DelTri.get_generator(vorn, i))) + !isempty(polygon_coords) && push!(generators, gp) + end + return generators, polygons +end + +# For heatmap-like inputs +function convert_arguments(::Type{<:Voronoiplot}, mat::AbstractMatrix) + return convert_arguments(PointBased(), axes(mat, 1), axes(mat, 2), mat) +end +convert_arguments(::Type{<:Voronoiplot}, xs, ys, zs) = convert_arguments(PointBased(), xs, ys, zs) +# For scatter-like inputs +convert_arguments(::Type{<:Voronoiplot}, ps) = convert_arguments(PointBased(), ps) +convert_arguments(::Type{<:Voronoiplot}, xs, ys) = convert_arguments(PointBased(), xs, ys) +convert_arguments(::Type{<:Voronoiplot}, x::DelTri.VoronoiTessellation) = (x,) + +function plot!(p::Voronoiplot{<:Tuple{<:Vector{<:Point{N}}}}) where {N} + attr = copy(p.attributes) + smooth = pop!(attr, :smooth) + + # from call pattern (::Vector, ::Vector, ::Matrix) + if N == 3 + ps = map(ps -> Point2f.(ps), p, p[1]) + attr[:color] = map(ps -> last.(ps), p, p[1]) + else + ps = p[1] + end + + # Handle transform_func early so tessellation is in cartesian space. + vorn = map(p, p.transformation.transform_func, ps, smooth) do tf, ps, smooth + transformed = Makie.apply_transform(tf, ps) + tri = DelTri.triangulate(transformed) + vorn = DelTri.voronoi(tri) + if smooth + vorn = DelTri.centroidal_smooth(vorn) + end + return vorn + end + + # Default to circular clip for polar transformed data + 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(p -> p[1 + tf.theta_as_x], ps) * (1 + ext) + return Circle(Point2f(0), rscaled) + else + return bb + end + end + attr[:transformation] = Transformation(p.transformation; transform_func=identity) + return voronoiplot!(p, attr, vorn) +end + +function data_limits(p::Voronoiplot{<:Tuple{<:Vector{<:Point{N}}}}) where {N} + if transform_func(p) isa Polar + # Because the Polar transform is handled explicitly we cannot rely + # on the default data_limits. (data limits are pre transform) + iter = (to_ndim(Point3f, p, 0f0) for p in p.converted[1][]) + limits_from_transformed_points(iter) + else + # First component is either another Voronoiplot or a poly plot. Both + # cases span the full limits of the plot + data_limits(p.plots[1]) + end +end + +function plot!(p::Voronoiplot{<:Tuple{<:DelTri.VoronoiTessellation}}) + generators_2f = Observable(Point2f[]) + PolyType = typeof(Polygon(Point2f[], [Point2f[]])) + polygons = Observable(PolyType[]) + + p.attributes[:_calculated_colors] = map(p, p.color, p[1]) do color, vorn + if color === automatic + # generate some consistent distinguishable colors + cs = [i for i in DelTri.each_generator(vorn)] + return cs + elseif color isa AbstractArray + @assert(length(color) == DelTri.num_points(DelTri.get_triangulation(vorn)), + "Color vector must have the same length as the number of generators, including any not yet in the tessellation.") + return [color[i] for i in DelTri.each_generator(vorn)] # this matches the polygon order + else + return color # constant color + end + end + + function update_plot(vorn) + if isempty(DelTri.get_unbounded_polygons(vorn)) + bbox = nothing + elseif p.clip[] === automatic + extent = p.unbounded_edge_extension_factor[] + bbox = DelTri.polygon_bounds(vorn, extent; include_polygon_vertices=false) + else + bbox = p.clip[] + end + get_voronoi_tiles!(generators_2f[], polygons[], vorn, bbox) + foreach(notify, (generators_2f, polygons)) + return + end + onany(update_plot, p, p[1]) + update_plot(p[1][]) + + poly!(p, polygons; + strokecolor=p.strokecolor, + strokewidth=p.strokewidth, + color=p._calculated_colors, + colormap=p.colormap, + colorscale=p.colorscale, + colorrange=p.colorrange, + lowclip=p.lowclip, + highclip=p.highclip, + nan_color=p.nan_color) + + scatter!(p, generators_2f; + markersize=p.markersize, + marker=p.marker, + color=p.markercolor, + visible=p.show_generators, + depth_shift=-2.0f-5) + + return p +end diff --git a/src/basic_recipes/waterfall.jl b/src/basic_recipes/waterfall.jl index 0d83e40221f..1aa0bf787cb 100644 --- a/src/basic_recipes/waterfall.jl +++ b/src/basic_recipes/waterfall.jl @@ -89,14 +89,19 @@ function Makie.plot!(p::Waterfall) xs = first( compute_x_and_width(first.(fromto.xy), width, gap, dodge, n_dodge, dodge_gap) ) - xy = similar(fromto.xy) - shapes = fill(marker_pos, length(xs)) + MarkerType = promote_type(typeof(marker_pos), typeof(marker_neg)) + DataType = eltype(fromto.xy) + shapes = MarkerType[] + xy = DataType[] for i in eachindex(xs) y = last(fromto.xy[i]) fillto = fromto.fillto[i] - xy[i] = (xs[i], (y + fillto) / 2) if fillto > y - shapes[i] = marker_neg + push!(xy, (xs[i], (y + fillto) / 2)) + push!(shapes, marker_neg) + elseif fillto < y + push!(xy, (xs[i], (y + fillto) / 2)) + push!(shapes, marker_pos) end end return (xy=xy, shapes=shapes) diff --git a/src/bezier.jl b/src/bezier.jl index d9074dcb7dd..a7754525fea 100644 --- a/src/bezier.jl +++ b/src/bezier.jl @@ -1,29 +1,31 @@ using StableHashTraits +const Point2d = Point2{Float64} + struct MoveTo - p::Point2{Float64} + p::Point2d end -MoveTo(x, y) = MoveTo(Point(x, y)) +MoveTo(x, y) = MoveTo(Point2d(x, y)) struct LineTo - p::Point2{Float64} + p::Point2d end -LineTo(x, y) = LineTo(Point(x, y)) +LineTo(x, y) = LineTo(Point2d(x, y)) struct CurveTo - c1::Point2{Float64} - c2::Point2{Float64} - p::Point2{Float64} + c1::Point2d + c2::Point2d + p::Point2d end CurveTo(cx1, cy1, cx2, cy2, p1, p2) = CurveTo( - Point(cx1, cy1), Point(cx2, cy2), Point(p1, p2) + Point2d(cx1, cy1), Point2d(cx2, cy2), Point2d(p1, p2) ) struct EllipticalArc - c::Point2{Float64} + c::Point2d r1::Float64 r2::Float64 angle::Float64 @@ -31,23 +33,88 @@ struct EllipticalArc a2::Float64 end -EllipticalArc(cx, cy, r1, r2, angle, a1, a2) = EllipticalArc(Point(cx, cy), +EllipticalArc(cx, cy, r1, r2, angle, a1, a2) = EllipticalArc(Point2d(cx, cy), r1, r2, angle, a1, a2) struct ClosePath end - const PathCommand = Union{MoveTo, LineTo, CurveTo, EllipticalArc, ClosePath} +function bbox(commands::Vector{PathCommand}) + prev = commands[1] + bb = nothing + for comm in @view(commands[2:end]) + if comm isa MoveTo || comm isa ClosePath + continue + else + endp = endpoint(prev) + _bb = cleanup_bbox(bbox(endp, comm)) + bb = bb === nothing ? _bb : union(bb, _bb) + end + prev = comm + end + return bb +end + +function elliptical_arc_to_beziers(arc::EllipticalArc) + delta_a = abs(arc.a2 - arc.a1) + n_beziers = ceil(Int, delta_a / 0.5pi) + angles = range(arc.a1, arc.a2; length=n_beziers + 1) + + startpoint = Point2f(cos(arc.a1), sin(arc.a1)) + curves = map(angles[1:(end - 1)], angles[2:end]) do start, stop + theta = stop - start + kappa = 4 / 3 * tan(theta / 4) + c1 = Point2f(cos(start) - kappa * sin(start), sin(start) + kappa * cos(start)) + c2 = Point2f(cos(stop) + kappa * sin(stop), sin(stop) - kappa * cos(stop)) + b = Point2f(cos(stop), sin(stop)) + return CurveTo(c1, c2, b) + end + + path = BezierPath([LineTo(startpoint), curves...]) + path = scale(path, Vec2{Float64}(arc.r1, arc.r2)) + path = rotate(path, arc.angle) + return translate(path, arc.c) +end + +bbox(p, x::Union{LineTo,CurveTo}) = bbox(segment(p, x)) +function bbox(p, e::EllipticalArc) + return bbox(elliptical_arc_to_beziers(e)) +end + +endpoint(m::MoveTo) = m.p +endpoint(l::LineTo) = l.p +endpoint(c::CurveTo) = c.p +function endpoint(e::EllipticalArc) + return point_at_angle(e, e.a2) +end + +function point_at_angle(e::EllipticalArc, theta) + M = abs(e.r1) * cos(theta) + N = abs(e.r2) * sin(theta) + return Point2f(e.c[1] + cos(e.angle) * M - sin(e.angle) * N, + e.c[2] + sin(e.angle) * M + cos(e.angle) * N) +end + +function cleanup_bbox(bb::Rect2f) + if any(x -> x < 0, bb.widths) + p = bb.origin .+ (bb.widths .< 0) .* bb.widths + return Rect2f(p, abs.(bb.widths)) + end + return bb +end + struct BezierPath commands::Vector{PathCommand} + boundingbox::Rect2f + hash::UInt32 + function BezierPath(commands::Vector) + c = convert(Vector{PathCommand}, commands) + return new(c, bbox(c), StableHashTraits.stable_hash(c; alg=crc32c, version=2)) + end end +bbox(x::BezierPath) = x.boundingbox +fast_stable_hash(x::BezierPath) = x.hash -StableHashTraits.transform(path::BezierPath) = path.commands -StableHashTraits.transform(c::EllipticalArc) = [c.c[1], c.c[2], c.r1, c.r2, c.angle, c.a1, c.a2] -StableHashTraits.transform(c::CurveTo) = [c.c1[1], c.c1[2], c.c2[1], c.c2[2], c.p[1], c.p[2]] -StableHashTraits.transform(c::LineTo) = [c.p[1], c.p[2]] -StableHashTraits.transform(c::MoveTo) = [c.p[1], c.p[2]] -StableHashTraits.transform(c::ClosePath) = 0 # so that the same bezierpath with a different instance of a vector hashes the same # and we don't create the same texture atlas entry twice @@ -59,7 +126,7 @@ function Base.:+(pc::P, p::Point2) where P <: PathCommand return P(map(f -> getfield(pc, f) + p, fnames)...) end -scale(bp::BezierPath, s::Real) = BezierPath([scale(x, Vec(s, s)) for x in bp.commands]) +scale(bp::BezierPath, s::Real) = BezierPath([scale(x, Vec2{Float64}(s, s)) for x in bp.commands]) scale(bp::BezierPath, v::VecTypes{2}) = BezierPath([scale(x, v) for x in bp.commands]) translate(bp::BezierPath, v::VecTypes{2}) = BezierPath([translate(x, v) for x in bp.commands]) @@ -121,7 +188,7 @@ function fit_to_bbox(b::BezierPath, bb_target::Rect2; keep_aspect = true) scale_factor end - bb_t = translate(scale(translate(b, -center_path), scale_factor_aspect), center_target) + return translate(scale(translate(b, -center_path), scale_factor_aspect), center_target) end function fit_to_unit_square(b::BezierPath, keep_aspect = true) @@ -134,74 +201,13 @@ Base.:+(bp::BezierPath, p::Point2) = BezierPath(bp.commands .+ Ref(p)) # markers that fit into a square with sidelength 1 centered on (0, 0) -const BezierCircle = let - r = 0.47 # sqrt(1/pi) - BezierPath([ - MoveTo(Point(r, 0.0)), - EllipticalArc(Point(0.0, 0), r, r, 0.0, 0.0, 2pi), - ClosePath(), - ]) -end - -const BezierUTriangle = let - aspect = 1 - h = 0.97 # sqrt(aspect) * sqrt(2) - w = 0.97 # 1/sqrt(aspect) * sqrt(2) - # r = Float32(sqrt(1 / (3 * sqrt(3) / 4))) - p1 = Point(0, h/2) - p2 = Point2(-w/2, -h/2) - p3 = Point2(w/2, -h/2) - centroid = (p1 + p2 + p3) / 3 - bp = BezierPath([ - MoveTo(p1 - centroid), - LineTo(p2 - centroid), - LineTo(p3 - centroid), - ClosePath() - ]) -end - -const BezierLTriangle = rotate(BezierUTriangle, pi/2) -const BezierDTriangle = rotate(BezierUTriangle, pi) -const BezierRTriangle = rotate(BezierUTriangle, 3pi/2) - - -const BezierSquare = let - r = 0.95 * sqrt(pi)/2/2 # this gives a little less area as the r=0.5 circle - BezierPath([ - MoveTo(Point2(r, -r)), - LineTo(Point2(r, r)), - LineTo(Point2(-r, r)), - LineTo(Point2(-r, -r)), - ClosePath() - ]) -end - -const BezierCross = let - cutfraction = 2/3 - r = 0.5 # 1/(2 * sqrt(1 - cutfraction^2)) - ri = 0.166 #r * (1 - cutfraction) - - first_three = Point2[(r, ri), (ri, ri), (ri, r)] - all = map(0:pi/2:3pi/2) do a - m = Mat2f(sin(a), cos(a), cos(a), -sin(a)) - Ref(m) .* first_three - end |> x -> reduce(vcat, x) - - BezierPath([ - MoveTo(all[1]), - LineTo.(all[2:end])..., - ClosePath() - ]) -end - -const BezierX = rotate(BezierCross, pi/4) function bezier_ngon(n, radius, angle) points = [radius * Point2f(cos(a + angle), sin(a + angle)) for a in range(0, 2pi, length = n+1)[1:end-1]] BezierPath([ MoveTo(points[1]); - LineTo.(points[2:end]); + LineTo.(@view points[2:end]); ClosePath() ]) end @@ -246,10 +252,10 @@ function BezierPath(svg::AbstractString; fit = false, bbox = nothing, flipy = fa commands = parse_bezier_commands(svg) p = BezierPath(commands) if flipy - p = scale(p, Vec(1, -1)) + p = scale(p, Vec2{Float64}(1, -1)) end if flipx - p = scale(p, Vec(-1, 1)) + p = scale(p, Vec2{Float64}(-1, 1)) end if fit if bbox === nothing @@ -271,27 +277,29 @@ function parse_bezier_commands(svg) commands = PathCommand[] lastcomm = nothing function lastp() - c = commands[end] if isnothing(lastcomm) - Point(0, 0) - elseif c isa ClosePath - r = reverse(commands) - backto = findlast(x -> !(x isa ClosePath), r) - if isnothing(backto) - error("No point to go back to") - end - r[backto].p - elseif c isa EllipticalArc - let - ϕ = c.angle - a2 = c.a2 - rx = c.r1 - ry = c.r2 - m = Mat2(cos(ϕ), sin(ϕ), -sin(ϕ), cos(ϕ)) - m * Point(rx * cos(a2), ry * sin(a2)) + c.c - end + Point2d(0, 0) else - c.p + c = commands[end] + if c isa ClosePath + r = reverse(commands) + backto = findlast(x -> !(x isa ClosePath), r) + if isnothing(backto) + error("No point to go back to") + end + r[backto].p + elseif c isa EllipticalArc + let + ϕ = c.angle + a2 = c.a2 + rx = c.r1 + ry = c.r2 + m = Mat2(cos(ϕ), sin(ϕ), -sin(ϕ), cos(ϕ)) + return m * Point2d(rx * cos(a2), ry * sin(a2)) + c.c + end + else + return c.p + end end end @@ -307,27 +315,27 @@ function parse_bezier_commands(svg) if comm == "M" x, y = parse.(Float64, args[i+1:i+2]) - push!(commands, MoveTo(Point2(x, y))) + push!(commands, MoveTo(Point2d(x, y))) i += 3 elseif comm == "m" x, y = parse.(Float64, args[i+1:i+2]) - push!(commands, MoveTo(Point2(x, y) + lastp())) + push!(commands, MoveTo(Point2d(x, y) + lastp())) i += 3 elseif comm == "L" x, y = parse.(Float64, args[i+1:i+2]) - push!(commands, LineTo(Point2(x, y))) + push!(commands, LineTo(Point2d(x, y))) i += 3 elseif comm == "l" x, y = parse.(Float64, args[i+1:i+2]) - push!(commands, LineTo(Point2(x, y) + lastp())) + push!(commands, LineTo(Point2d(x, y) + lastp())) i += 3 elseif comm == "H" x = parse(Float64, args[i+1]) - push!(commands, LineTo(Point2(x, lastp()[2]))) + push!(commands, LineTo(Point2d(x, lastp()[2]))) i += 2 elseif comm == "h" x = parse(Float64, args[i+1]) - push!(commands, LineTo(X(x) + lastp())) + push!(commands, LineTo(Point2d(x, 0) + lastp())) i += 2 elseif comm == "Z" push!(commands, ClosePath()) @@ -337,25 +345,25 @@ function parse_bezier_commands(svg) i += 1 elseif comm == "C" x1, y1, x2, y2, x3, y3 = parse.(Float64, args[i+1:i+6]) - push!(commands, CurveTo(Point2(x1, y1), Point2(x2, y2), Point2(x3, y3))) + push!(commands, CurveTo(Point2d(x1, y1), Point2d(x2, y2), Point2d(x3, y3))) i += 7 elseif comm == "c" x1, y1, x2, y2, x3, y3 = parse.(Float64, args[i+1:i+6]) l = lastp() - push!(commands, CurveTo(Point2(x1, y1) + l, Point2(x2, y2) + l, Point2(x3, y3) + l)) + push!(commands, CurveTo(Point2d(x1, y1) + l, Point2d(x2, y2) + l, Point2d(x3, y3) + l)) i += 7 elseif comm == "S" x1, y1, x2, y2 = parse.(Float64, args[i+1:i+4]) prev = commands[end] reflected = prev.p + (prev.p - prev.c2) - push!(commands, CurveTo(reflected, Point2(x1, y1), Point2(x2, y2))) + push!(commands, CurveTo(reflected, Point2d(x1, y1), Point2d(x2, y2))) i += 5 elseif comm == "s" x1, y1, x2, y2 = parse.(Float64, args[i+1:i+4]) prev = commands[end] reflected = prev.p + (prev.p - prev.c2) l = lastp() - push!(commands, CurveTo(reflected, Point2(x1, y1) + l, Point2(x2, y2) + l)) + push!(commands, CurveTo(reflected, Point2d(x1, y1) + l, Point2d(x2, y2) + l)) i += 5 elseif comm == "A" args[i+1:i+7] @@ -381,12 +389,12 @@ function parse_bezier_commands(svg) elseif comm == "v" dy = parse(Float64, args[i+1]) l = lastp() - push!(commands, LineTo(Point2(l[1], l[2] + dy))) + push!(commands, LineTo(Point2d(l[1], l[2] + dy))) i += 2 elseif comm == "V" y = parse(Float64, args[i+1]) l = lastp() - push!(commands, LineTo(Point2(l[1], y))) + push!(commands, LineTo(Point2d(l[1], y))) i += 2 else for c in commands @@ -405,8 +413,8 @@ end function EllipticalArc(x1, y1, x2, y2, rx, ry, ϕ, largearc::Bool, sweepflag::Bool) # https://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes - p1 = Point(x1, y1) - p2 = Point(x2, y2) + p1 = Point2d(x1, y1) + p2 = Point2d(x2, y2) m1 = Mat2(cos(ϕ), -sin(ϕ), sin(ϕ), cos(ϕ)) x1′, y1′ = m1 * (0.5 * (p1 - p2)) @@ -415,16 +423,16 @@ function EllipticalArc(x1, y1, x2, y2, rx, ry, ϕ, largearc::Bool, sweepflag::Bo (rx^2 * y1′^2 + ry^2 * x1′^2) c′ = (largearc == sweepflag ? -1 : 1) * - sqrt(tempsqrt) * Point(rx * y1′ / ry, -ry * x1′ / rx) + sqrt(tempsqrt) * Point2d(rx * y1′ / ry, -ry * x1′ / rx) c = Mat2(cos(ϕ), sin(ϕ), -sin(ϕ), cos(ϕ)) * c′ + 0.5 * (p1 + p2) vecangle(u, v) = sign(u[1] * v[2] - u[2] * v[1]) * acos(dot(u, v) / (norm(u) * norm(v))) - px(sign) = Point((sign * x1′ - c′[1]) / rx, (sign * y1′ - c′[2]) / rx) + px(sign) = Point2d((sign * x1′ - c′[1]) / rx, (sign * y1′ - c′[2]) / rx) - θ1 = vecangle(Point(1.0, 0.0), px(1)) + θ1 = vecangle(Point2d(1.0, 0.0), px(1)) Δθ_pre = mod(vecangle(px(1), px(-1)), 2pi) Δθ = if Δθ_pre > 0 && !sweepflag Δθ_pre - 2pi @@ -447,7 +455,6 @@ function make_outline(path) points = FT_Vector[] tags = Int8[] contours = Int16[] - flags = Int32(0) for command in path.commands new_contour, n_newpoints, newpoints, newtags = convert_command(command) if new_contour @@ -496,22 +503,30 @@ function render_path(path, bitmap_size_px = 256) scale_factor = bitmap_size_px * 64 # We transform the path into a rectangle of size (aspect, 1) or (1, aspect) - # such that aspect ≤ 1. We then scale that rectangle up to a size of 4096 by + # such that aspect ≤ 1. We then scale that rectangle up to a size of 4096 by # 4096 * aspect, which results in at most a 64px by 64px bitmap # freetype has no ClosePath and EllipticalArc, so those need to be replaced path_replaced = replace_nonfreetype_commands(path) - aspect = widths(bbox(path)) / maximum(widths(bbox(path))) - path_unit_rect = fit_to_bbox(path_replaced, Rect2f(Point2f(0), aspect)) + # Minimal size that becomes integer when mutliplying by 64 (target size for + # atlas). This adds padding to avoid blurring/scaling factors from rounding + # during sdf generation + path_size = widths(bbox(path)) / maximum(widths(bbox(path))) + w = ceil(Int, 64 * path_size[1]) + h = ceil(Int, 64 * path_size[2]) + path_size = Vec2f(w, h) / 64f0 + + path_unit_rect = fit_to_bbox(path_replaced, Rect2f(Point2f(0), path_size)) path_transformed = Makie.scale(path_unit_rect, scale_factor) outline_ref = make_outline(path_transformed) - # Adjust bitmap size to match path aspect - w = ceil(Int, bitmap_size_px * aspect[1]) - h = ceil(Int, bitmap_size_px * aspect[2]) + # Adjust bitmap size to match path size + w = ceil(Int, bitmap_size_px * path_size[1]) + h = ceil(Int, bitmap_size_px * path_size[2]) + pitch = w * 1 # 8 bit gray pixelbuffer = zeros(UInt8, h * pitch) bitmap_ref = Ref{FT_Bitmap}() @@ -578,60 +593,12 @@ struct LineSegment to::Point2f end -function bbox(b::BezierPath) - prev = b.commands[1] - bb = nothing - for comm in b.commands[2:end] - if comm isa MoveTo || comm isa ClosePath - continue - else - endp = endpoint(prev) - _bb = cleanup_bbox(bbox(endp, comm)) - bb = bb === nothing ? _bb : union(bb, _bb) - end - prev = comm - end - bb -end - -segment(p, l::LineTo) = LineSegment(p, l.p) -segment(p, c::CurveTo) = BezierSegment(p, c.c1, c.c2, c.p) - -endpoint(m::MoveTo) = m.p -endpoint(l::LineTo) = l.p -endpoint(c::CurveTo) = c.p -function endpoint(e::EllipticalArc) - point_at_angle(e, e.a2) -end - -function point_at_angle(e::EllipticalArc, theta) - M = abs(e.r1) * cos(theta) - N = abs(e.r2) * sin(theta) - Point2f( - e.c[1] + cos(e.angle) * M - sin(e.angle) * N, - e.c[2] + sin(e.angle) * M + cos(e.angle) * N - ) -end - -function cleanup_bbox(bb::Rect2f) - if any(x -> x < 0, bb.widths) - p = bb.origin .+ (bb.widths .< 0) .* bb.widths - return Rect2f(p, abs.(bb.widths)) - end - return bb -end - -bbox(p, x::Union{LineTo, CurveTo}) = bbox(segment(p, x)) -function bbox(p, e::EllipticalArc) - bbox(elliptical_arc_to_beziers(e)) -end function bbox(ls::LineSegment) - Rect2f(ls.from, ls.to - ls.from) + return Rect2f(ls.from, ls.to - ls.from) end function bbox(b::BezierSegment) - p0 = b.from p1 = b.c1 p2 = b.c2 @@ -641,68 +608,103 @@ function bbox(b::BezierSegment) ma = [max.(p0, p3)...] c = -p0 + p1 - b = p0 - 2p1 + p2 + b = p0 - 2p1 + p2 a = -p0 + 3p1 - 3p2 + 1p3 - h = [(b.*b - a.*c)...] + h = [(b .* b - a .* c)...] if h[1] > 0 h[1] = sqrt(h[1]) t = (-b[1] - h[1]) / a[1] if t > 0 && t < 1 - s = 1.0-t - q = s*s*s*p0[1] + 3.0*s*s*t*p1[1] + 3.0*s*t*t*p2[1] + t*t*t*p3[1] - mi[1] = min(mi[1],q) - ma[1] = max(ma[1],q) + s = 1.0 - t + q = s * s * s * p0[1] + 3.0 * s * s * t * p1[1] + 3.0 * s * t * t * p2[1] + t * t * t * p3[1] + mi[1] = min(mi[1], q) + ma[1] = max(ma[1], q) end - t = (-b[1] + h[1])/a[1] - if t>0 && t<1 - s = 1.0-t - q = s*s*s*p0[1] + 3.0*s*s*t*p1[1] + 3.0*s*t*t*p2[1] + t*t*t*p3[1] - mi[1] = min(mi[1],q) - ma[1] = max(ma[1],q) + t = (-b[1] + h[1]) / a[1] + if t > 0 && t < 1 + s = 1.0 - t + q = s * s * s * p0[1] + 3.0 * s * s * t * p1[1] + 3.0 * s * t * t * p2[1] + t * t * t * p3[1] + mi[1] = min(mi[1], q) + ma[1] = max(ma[1], q) end end - if h[2]>0.0 + if h[2] > 0.0 h[2] = sqrt(h[2]) - t = (-b[2] - h[2])/a[2] - if t>0.0 && t<1.0 - s = 1.0-t - q = s*s*s*p0[2] + 3.0*s*s*t*p1[2] + 3.0*s*t*t*p2[2] + t*t*t*p3[2] - mi[2] = min(mi[2],q) - ma[2] = max(ma[2],q) + t = (-b[2] - h[2]) / a[2] + if t > 0.0 && t < 1.0 + s = 1.0 - t + q = s * s * s * p0[2] + 3.0 * s * s * t * p1[2] + 3.0 * s * t * t * p2[2] + t * t * t * p3[2] + mi[2] = min(mi[2], q) + ma[2] = max(ma[2], q) end - t = (-b[2] + h[2])/a[2] - if t>0.0 && t<1.0 - s = 1.0-t - q = s*s*s*p0[2] + 3.0*s*s*t*p1[2] + 3.0*s*t*t*p2[2] + t*t*t*p3[2] - mi[2] = min(mi[2],q) - ma[2] = max(ma[2],q) + t = (-b[2] + h[2]) / a[2] + if t > 0.0 && t < 1.0 + s = 1.0 - t + q = s * s * s * p0[2] + 3.0 * s * s * t * p1[2] + 3.0 * s * t * t * p2[2] + t * t * t * p3[2] + mi[2] = min(mi[2], q) + ma[2] = max(ma[2], q) end end - Rect2f(Point(mi...), Point(ma...) - Point(mi...)) + return Rect2f(Point(mi...), Point(ma...) - Point(mi...)) end +segment(p, l::LineTo) = LineSegment(p, l.p) +segment(p, c::CurveTo) = BezierSegment(p, c.c1, c.c2, c.p) -function elliptical_arc_to_beziers(arc::EllipticalArc) - delta_a = abs(arc.a2 - arc.a1) - n_beziers = ceil(Int, delta_a / 0.5pi) - angles = range(arc.a1, arc.a2, length = n_beziers + 1) - startpoint = Point2f(cos(arc.a1), sin(arc.a1)) - curves = map(angles[1:end-1], angles[2:end]) do start, stop - theta = stop - start - kappa = 4/3 * tan(theta/4) - c1 = Point2f(cos(start) - kappa * sin(start), sin(start) + kappa * cos(start)) - c2 = Point2f(cos(stop) + kappa * sin(stop), sin(stop) - kappa * cos(stop)) - b = Point2f(cos(stop), sin(stop)) - CurveTo(c1, c2, b) - end +const BezierCircle = let + r = 0.47 # sqrt(1/pi) + BezierPath([MoveTo(Point(r, 0.0)), + EllipticalArc(Point(0.0, 0), r, r, 0.0, 0.0, 2pi), + ClosePath()]) +end - path = BezierPath([LineTo(startpoint), curves...]) - path = scale(path, Vec(arc.r1, arc.r2)) - path = rotate(path, arc.angle) - path = translate(path, arc.c) +const BezierUTriangle = let + aspect = 1 + h = 0.97 # sqrt(aspect) * sqrt(2) + w = 0.97 # 1/sqrt(aspect) * sqrt(2) + # r = Float32(sqrt(1 / (3 * sqrt(3) / 4))) + p1 = Point(0, h / 2) + p2 = Point2d(-w / 2, -h / 2) + p3 = Point2d(w / 2, -h / 2) + centroid = (p1 + p2 + p3) / 3 + bp = BezierPath([MoveTo(p1 - centroid), + LineTo(p2 - centroid), + LineTo(p3 - centroid), + ClosePath()]) end + +const BezierLTriangle = rotate(BezierUTriangle, pi / 2) +const BezierDTriangle = rotate(BezierUTriangle, pi) +const BezierRTriangle = rotate(BezierUTriangle, 3pi / 2) + +const BezierSquare = let + r = 0.95 * sqrt(pi) / 2 / 2 # this gives a little less area as the r=0.5 circle + BezierPath([MoveTo(Point2d(r, -r)), + LineTo(Point2d(r, r)), + LineTo(Point2d(-r, r)), + LineTo(Point2d(-r, -r)), + ClosePath()]) +end + +const BezierCross = let + cutfraction = 2 / 3 + r = 0.5 # 1/(2 * sqrt(1 - cutfraction^2)) + ri = 0.166 #r * (1 - cutfraction) + + first_three = Point2d[(r, ri), (ri, ri), (ri, r)] + all = (x -> reduce(vcat, x))(map(0:(pi / 2):(3pi / 2)) do a + m = Mat2f(sin(a), cos(a), cos(a), -sin(a)) + return Ref(m) .* first_three + end) + + BezierPath([MoveTo(all[1]), + LineTo.(all[2:end])..., + ClosePath()]) +end + +const BezierX = rotate(BezierCross, pi / 4) diff --git a/src/camera/camera.jl b/src/camera/camera.jl index 48d63063fa7..2d75f3dc9ca 100644 --- a/src/camera/camera.jl +++ b/src/camera/camera.jl @@ -1,5 +1,5 @@ function Base.copy(x::Camera) - Camera(ntuple(7) do i + Camera(ntuple(9) do i getfield(x, i) end...) end @@ -18,6 +18,7 @@ function Base.show(io::IO, camera::Camera) println(io, " projection: ", camera.projection[]) println(io, " projectionview: ", camera.projectionview[]) println(io, " resolution: ", camera.resolution[]) + println(io, " lookat: ", camera.lookat[]) println(io, " eyeposition: ", camera.eyeposition[]) end @@ -67,8 +68,8 @@ function Observables.on(f, camera::Camera, observables::AbstractObservable...; p return f end -function Camera(px_area) - pixel_space = lift(px_area) do window_size +function Camera(viewport) + pixel_space = lift(viewport) do window_size nearclip = -10_000f0 farclip = 10_000f0 w, h = Float32.(widths(window_size)) @@ -82,9 +83,11 @@ function Camera(px_area) view, proj, proj_view, - lift(a-> Vec2f(widths(a)), px_area), + lift(a-> Vec2f(widths(a)), viewport), + Observable(Vec3f(0)), Observable(Vec3f(1)), - ObserverFunction[] + ObserverFunction[], + Dict{Symbol, Observable}() ) end @@ -100,7 +103,7 @@ end is_mouseinside(x, target) = is_mouseinside(get_scene(x), target) function is_mouseinside(scene::Scene, target) scene === target && return false - Vec(scene.events.mouseposition[]) in pixelarea(scene)[] || return false + Vec(scene.events.mouseposition[]) in viewport(scene)[] || return false for child in r.children is_mouseinside(child, target) && return true end @@ -114,7 +117,7 @@ Returns true if the current mouseposition is inside the given scene. """ is_mouseinside(x) = is_mouseinside(get_scene(x)) function is_mouseinside(scene::Scene) - return Vec(scene.events.mouseposition[]) in pixelarea(scene)[] + return Vec(scene.events.mouseposition[]) in viewport(scene)[] # Check that mouse is not inside any other screen # for child in scene.children # is_mouseinside(child) && return false diff --git a/src/camera/camera2d.jl b/src/camera/camera2d.jl index ff276348d94..d9d354a180f 100644 --- a/src/camera/camera2d.jl +++ b/src/camera/camera2d.jl @@ -1,8 +1,8 @@ struct Camera2D <: AbstractCamera area::Observable{Rect2f} zoomspeed::Observable{Float32} - zoombutton::Observable{ButtonTypes} - panbutton::Observable{Union{ButtonTypes, Vector{ButtonTypes}}} + zoombutton::Observable{IsPressedInputType} + panbutton::Observable{IsPressedInputType} padding::Observable{Float32} last_area::Observable{Vec{2, Int}} update_limits::Observable{Bool} @@ -11,14 +11,23 @@ end """ cam2d!(scene::SceneLike, kwargs...) -Creates a 2D camera for the given Scene. +Creates a 2D camera for the given `scene`. The camera implements zooming by +scrolling and translation using mouse drag. It also implements rectangle +selections. + +## Keyword Arguments + +- `zoomspeed = 0.1f0` sets the zoom speed. +- `zoombutton = true` sets a button (combination) which needs to be pressed to enable zooming. By default no button needs to be pressed. +- `panbutton = Mouse.right` sets the button used to translate the camera. This must include a mouse button. +- `selectionbutton = (Keyboard.space, Mouse.left)` sets the button used for rectangle selection. This must include a mouse button. """ function cam2d!(scene::SceneLike; kw_args...) cam_attributes = merged_get!(:cam2d, scene, Attributes(kw_args)) do Attributes( area = Observable(Rectf(0, 0, 1, 1)), zoomspeed = 0.10f0, - zoombutton = nothing, + zoombutton = true, panbutton = Mouse.right, selectionbutton = (Keyboard.space, Mouse.left), padding = 0.001, @@ -37,6 +46,7 @@ function cam2d!(scene::SceneLike; kw_args...) cam end +get_space(::Camera2D) = :data wscale(screenrect, viewrect) = widths(viewrect) ./ widths(screenrect) @@ -45,7 +55,14 @@ wscale(screenrect, viewrect) = widths(viewrect) ./ widths(screenrect) Updates the camera for the given `scene` to cover the given `area` in 2d. """ -update_cam!(scene::SceneLike, area) = update_cam!(scene, cameracontrols(scene), area) +function update_cam!(scene::SceneLike, area::Rect) + return update_cam!(scene, cameracontrols(scene), area) +end +function update_cam!(scene::SceneLike, area::Rect, center::Bool) + return update_cam!(scene, cameracontrols(scene), area, center) +end + + """ update_cam!(scene::SceneLike) @@ -60,7 +77,7 @@ function update_cam!(scene::Scene, cam::Camera2D, area3d::Rect) # ignore rects with width almost 0 any(x-> x ≈ 0.0, widths(area)) && return - pa = pixelarea(scene)[] + pa = viewport(scene)[] px_wh = normalize(widths(pa)) wh = normalize(widths(area)) ratio = px_wh ./ wh @@ -89,7 +106,7 @@ function update_cam!(scene::SceneLike, cam::Camera2D) end function correct_ratio!(scene, cam) - on(camera(scene), pixelarea(scene)) do area + on(camera(scene), viewport(scene)) do area neww = widths(area) change = neww .- cam.last_area[] if !(change ≈ Vec(0.0, 0.0)) @@ -123,7 +140,7 @@ function add_pan!(scene::SceneLike, cam::Camera2D) diff = startpos[] .- mp startpos[] = mp area = cam.area[] - diff = Vec(diff) .* wscale(pixelarea(scene)[], area) + diff = Vec(diff) .* wscale(viewport(scene)[], area) cam.area[] = Rectf(minimum(area) .+ diff, widths(area)) update_cam!(scene, cam) active[] = false @@ -141,7 +158,7 @@ function add_pan!(scene::SceneLike, cam::Camera2D) diff = startpos[] .- pos startpos[] = pos area = cam.area[] - diff = Vec(diff) .* wscale(pixelarea(scene)[], area) + diff = Vec(diff) .* wscale(viewport(scene)[], area) cam.area[] = Rectf(minimum(area) .+ diff, widths(area)) update_cam!(scene, cam) return Consume(true) @@ -156,7 +173,7 @@ function add_zoom!(scene::SceneLike, cam::Camera2D) @extractvalue cam (zoomspeed, zoombutton, area) zoom = Float32(x[2]) if zoom != 0 && ispressed(scene, zoombutton) && is_mouseinside(scene) - pa = pixelarea(scene)[] + pa = viewport(scene)[] z = (1f0 - zoomspeed)^zoom mp = Vec2f(e.mouseposition[]) - minimum(pa) mp = (mp .* wscale(pa, area)) + minimum(area) @@ -173,7 +190,7 @@ function add_zoom!(scene::SceneLike, cam::Camera2D) end function camspace(scene::SceneLike, cam::Camera2D, point) - point = Vec(point) .* wscale(pixelarea(scene)[], cam.area[]) + point = Vec(point) .* wscale(viewport(scene)[], cam.area[]) return Vec(point) .+ Vec(minimum(cam.area[])) end @@ -301,6 +318,7 @@ function add_restriction!(cam, window, rarea::Rect2, minwidths::Vec) end struct PixelCamera <: AbstractCamera end +get_space(::PixelCamera) = :pixel struct UpdatePixelCam @@ -308,6 +326,7 @@ struct UpdatePixelCam near::Float32 far::Float32 end +get_space(::UpdatePixelCam) = :pixel function (cam::UpdatePixelCam)(window_size) w, h = Float32.(widths(window_size)) @@ -318,27 +337,31 @@ end """ campixel!(scene; nearclip=-1000f0, farclip=1000f0) -Creates a pixel-level camera for the `Scene`. No controls! +Creates a pixel camera for the given `scene`. This means that the positional +data of a plot will be interpreted in pixel units. This camera does not feature +controls. """ function campixel!(scene::Scene; nearclip=-10_000f0, farclip=10_000f0) disconnect!(camera(scene)) update_once = Observable(false) closure = UpdatePixelCam(camera(scene), nearclip, farclip) - on(closure, camera(scene), pixelarea(scene)) + on(closure, camera(scene), viewport(scene)) cam = PixelCamera() # update once - closure(pixelarea(scene)[]) + closure(viewport(scene)[]) cameracontrols!(scene, cam) update_once[] = true return cam end struct RelativeCamera <: AbstractCamera end +get_space(::RelativeCamera) = :relative """ cam_relative!(scene) -Creates a pixel-level camera for the `Scene`. No controls! +Creates a camera for the given `scene` which maps the scene area to a 0..1 by +0..1 range. This camera does not feature controls. """ function cam_relative!(scene::Scene; nearclip=-10_000f0, farclip=10_000f0) projection = orthographicprojection(0f0, 1f0, 0f0, 1f0, nearclip, farclip) diff --git a/src/camera/camera3d.jl b/src/camera/camera3d.jl index 2b695aa0043..6c8d72d0647 100644 --- a/src/camera/camera3d.jl +++ b/src/camera/camera3d.jl @@ -1,36 +1,66 @@ -struct Camera3D <: AbstractCamera +abstract type AbstractCamera3D <: AbstractCamera end + +get_space(::AbstractCamera3D) = :data + +struct Camera3D <: AbstractCamera3D + # User settings + settings::Attributes + controls::Attributes + + # Interactivity + pulser::Observable{Float64} + selected::Observable{Bool} + + # view matrix eyeposition::Observable{Vec3f} lookat::Observable{Vec3f} upvector::Observable{Vec3f} - zoom_mult::Observable{Float32} - fov::Observable{Float32} # WGLMakie compat + # perspective projection matrix + fov::Observable{Float32} near::Observable{Float32} far::Observable{Float32} - pulser::Observable{Float64} - - attributes::Attributes + bounding_sphere::Observable{Sphere{Float32}} end """ - Camera3D(scene[; attributes...]) + Camera3D(scene[; kwargs...]) -Creates a 3d camera with a lot of controls. +Sets up a 3D camera with mouse and keyboard controls. -The 3D camera is (or can be) unrestricted in terms of rotations and translations. Both `cam3d!(scene)` and `cam3d_cad!(scene)` create this camera type. Unlike the 2D camera, settings and controls are stored in the `cam.attributes` field rather than in the struct directly, but can still be passed as keyword arguments. The general camera settings include +The behavior of the camera can be adjusted via keyword arguments or the fields +`settings` and `controls`. + +## Settings + +Settings include anything that isn't a mouse or keyboard button. -- `fov = 45f0` sets the "neutral" field of view, i.e. the fov corresponding to no zoom. This is irrelevant if the camera uses an orthographic projection. -- `near = automatic` sets the value of the near clip. By default this will be chosen based on the scenes bounding box. The final value is in `cam.near`. -- `far = automatic` sets the value of the far clip. By default this will be chosen based on the scenes bounding box. The final value is in `cam.far`. -- `rotation_center = :lookat` sets the default center for camera rotations. Currently allows `:lookat` or `:eyeposition`. - `projectiontype = Perspective` sets the type of the projection. Can be `Orthographic` or `Perspective`. -- `fixed_axis = false`: If true panning uses the (world/plot) z-axis instead of the camera up direction. -- `zoom_shift_lookat = true`: If true attempts to keep data under the cursor in view when zooming. +- `rotation_center = :lookat` sets the default center for camera rotations. Currently allows `:lookat` or `:eyeposition`. +- `fixed_axis = true`: If true panning uses the (world/plot) z-axis instead of the camera up direction. +- `zoom_shift_lookat = true`: If true keeps the data under the cursor when zooming. - `cad = false`: If true rotates the view around `lookat` when zooming off-center. +- `clipping_mode = :adaptive`: Controls how `near` and `far` get processed. Options: + - `:static` passes `near` and `far` as is + - `:adaptive` scales `near` by `norm(eyeposition - lookat)` and passes `far` as is + - `:view_relative` scales `near` and `far` by `norm(eyeposition - lookat)` + - `:bbox_relative` scales `near` and `far` to the scene bounding box as passed to the camera with `update_cam!(..., bbox)`. (More specifically `far = 1` is scaled to the furthest point of a bounding sphere and `near` is generally overwritten to be the closest point.) +- `center = true`: Controls whether the camera placement gets reset when calling `center!(scene)`, which is called when a new plot is added. + +- `keyboard_rotationspeed = 1f0` sets the speed of keyboard based rotations. +- `keyboard_translationspeed = 0.5f0` sets the speed of keyboard based translations. +- `keyboard_zoomspeed = 1f0` sets the speed of keyboard based zooms. -The camera view follows from the position of the camera `eyeposition`, the point which the camera focuses `lookat` and the up direction of the camera `upvector`. These can be accessed as `cam.eyeposition` etc and adjusted via `update_cam!(scene, cameracontrols(scene), eyeposition, lookat[, upvector = Vec3f(0, 0, 1)])`. They can also be passed as keyword arguments when the camera is constructed. +- `mouse_rotationspeed = 1f0` sets the speed of mouse rotations. +- `mouse_translationspeed = 0.5f0` sets the speed of mouse translations. +- `mouse_zoomspeed = 1f0` sets the speed of mouse zooming (mousewheel). + +- `update_rate = 1/30` sets the rate at which keyboard based camera updates are evaluated. +- `circular_rotation = (true, true, true)` enables circular rotations for (fixed x, fixed y, fixed z) rotation axis. (This means drawing a circle with your mouse around the center of the scene will result in a continuous rotation.) -The camera can be controlled by keyboard and mouse. The keyboard has the following available attributes +## Controls + +Controls include any kind of hotkey setting. - `up_key = Keyboard.r` sets the key for translations towards the top of the screen. - `down_key = Keyboard.f` sets the key for translations towards the bottom of the screen. @@ -39,10 +69,10 @@ The camera can be controlled by keyboard and mouse. The keyboard has the followi - `forward_key = Keyboard.w` sets the key for translations into the screen. - `backward_key = Keyboard.s` sets the key for translations out of the screen. -- `zoom_in_key = Keyboard.u` sets the key for zooming into the scene (enlarge, via fov). -- `zoom_out_key = Keyboard.o` sets the key for zooming out of the scene (shrink, via fov). -- `stretch_view_key = Keyboard.page_up` sets the key for moving `eyepostion` away from `lookat`. -- `contract_view_key = Keyboard.page_down` sets the key for moving `eyeposition` towards `lookat`. +- `zoom_in_key = Keyboard.u` sets the key for zooming into the scene (translate eyeposition towards lookat). +- `zoom_out_key = Keyboard.o` sets the key for zooming out of the scene (translate eyeposition away from lookat). +- `increase_fov_key = Keyboard.b` sets the key for increasing the fov. +- `decrease_fov_key = Keyboard.n` sets the key for decreasing the fov. - `pan_left_key = Keyboard.j` sets the key for rotations around the screens vertical axis. - `pan_right_key = Keyboard.l` sets the key for rotations around the screens vertical axis. @@ -51,101 +81,123 @@ The camera can be controlled by keyboard and mouse. The keyboard has the followi - `roll_clockwise_key = Keyboard.e` sets the key for rotations of the screen. - `roll_counterclockwise_key = Keyboard.q` sets the key for rotations of the screen. -- `keyboard_rotationspeed = 1f0` sets the speed of keyboard based rotations. -- `keyboard_translationspeed = 0.5f0` sets the speed of keyboard based translations. -- `keyboard_zoomspeed = 1f0` sets the speed of keyboard based zooms. -- `update_rate = 1/30` sets the rate at which keyboard based camera updates are evaluated. - -and mouse interactions are controlled by +- `fix_x_key = Keyboard.x` sets the key for fixing translations and rotations to the (world/plot) x-axis. +- `fix_y_key = Keyboard.y` sets the key for fixing translations and rotations to the (world/plot) y-axis. +- `fix_z_key = Keyboard.z` sets the key for fixing translations and rotations to the (world/plot) z-axis. +- `reset = Keyboard.left_control & Mouse.left` sets the key for resetting the camera. This equivalent to calling `center!(scene)`. +- `reposition_button = Keyboard.left_alt & Mouse.left` sets the key for focusing the camera on a plot object. - `translation_button = Mouse.right` sets the mouse button for drag-translations. (up/down/left/right) - `scroll_mod = true` sets an additional modifier button for scroll-based zoom. (true being neutral) - `rotation_button = Mouse.left` sets the mouse button for drag-rotations. (pan, tilt) -- `mouse_rotationspeed = 1f0` sets the speed of mouse rotations. -- `mouse_translationspeed = 0.5f0` sets the speed of mouse translations. -- `mouse_zoomspeed = 1f0` sets the speed of mouse zooming (mousewheel). -- `circular_rotation = (true, true, true)` enables circular rotations for (fixed x, fixed y, fixed z) rotation axis. (This means drawing a circle with your mouse around the center of the scene will result in a continuous rotation.) +## Other kwargs -There are also a few generally applicable controls: +Some keyword arguments are used to initialize fields. These include -- `fix_x_key = Keyboard.x` sets the key for fixing translations and rotations to the (world/plot) x-axis. -- `fix_y_key = Keyboard.y` sets the key for fixing translations and rotations to the (world/plot) y-axis. -- `fix_z_key = Keyboard.z` sets the key for fixing translations and rotations to the (world/plot) z-axis. -- `reset = Keyboard.home` sets the key for fully resetting the camera. This equivalent to setting `lookat = Vec3f(0)`, `upvector = Vec3f(0, 0, 1)`, `eyeposition = Vec3f(3)` and then calling `center!(scene)`. +- `eyeposition = Vec3f(3)`: The position of the camera. +- `lookat = Vec3f(0)`: The point the camera is focused on. +- `upvector = Vec3f(0, 0, 1)`: The world direction corresponding to the up direction of the screen. + +- `fov = 45.0` is the field of view. This is irrelevant if the camera uses an orthographic projection. +- `near = automatic` sets the position of the near clip plane. Anything between the camera and the near clip plane is hidden. Must be greater 0. Usage depends on `clipping_mode`. +- `far = automatic` sets the position of the far clip plane. Anything further away than the far clip plane is hidden. Usage depends on `clipping_mode`. Defaults to `1` for `clipping_mode = :bbox_relative`, `2` for `:view_relative` or a value derived from limits for `:static`. -You can also make adjustments to the camera position, rotation and zoom by calling relevant functions: +Note that updating these observables in an active camera requires a call to `update_cam(scene)` +for them to be applied. For updating `eyeposition`, `lookat` and/or upvector +`update_cam!(scene, eyeposition, lookat, upvector = Vec3f(0,0,1))` is preferred. + +The camera position and orientation can also be adjusted via the functions - `translate_cam!(scene, v)` will translate the camera by the given world/plot space vector `v`. - `rotate_cam!(scene, angles)` will rotate the camera around its axes with the corresponding angles. The first angle will rotate around the cameras "right" that is the screens horizontal axis, the second around the up vector/vertical axis or `Vec3f(0, 0, +-1)` if `fixed_axis = true`, and the third will rotate around the view direction i.e. the axis out of the screen. The rotation respects the current `rotation_center` of the camera. - `zoom!(scene, zoom_step)` will change the zoom level of the scene without translating or rotating the scene. `zoom_step` applies multiplicatively to `cam.zoom_mult` which is used as a multiplier to the fov (perspective projection) or width and height (orthographic projection). """ function Camera3D(scene::Scene; kwargs...) - attr = merged_get!(:cam3d, scene, Attributes(kwargs)) do - Attributes( - # Keyboard controls - # Translations - up_key = Keyboard.r, - down_key = Keyboard.f, - left_key = Keyboard.a, - right_key = Keyboard.d, - forward_key = Keyboard.w, - backward_key = Keyboard.s, - # Zooms - zoom_in_key = Keyboard.u, - zoom_out_key = Keyboard.o, - stretch_view_key = Keyboard.page_up, - contract_view_key = Keyboard.page_down, - # Rotations - pan_left_key = Keyboard.j, - pan_right_key = Keyboard.l, - tilt_up_key = Keyboard.i, - tilt_down_key = Keyboard.k, - roll_clockwise_key = Keyboard.e, - roll_counterclockwise_key = Keyboard.q, - # Mouse controls - translation_button = Mouse.right, - scroll_mod = true, - rotation_button = Mouse.left, - # Shared controls - fix_x_key = Keyboard.x, - fix_y_key = Keyboard.y, - fix_z_key = Keyboard.z, - reset = Keyboard.home, - # Settings - keyboard_rotationspeed = 1f0, - keyboard_translationspeed = 0.5f0, - keyboard_zoomspeed = 1f0, - mouse_rotationspeed = 1f0, - mouse_translationspeed = 1f0, - mouse_zoomspeed = 1f0, - circular_rotation = (true, true, true), - fov = 45f0, # base fov - near = automatic, - far = automatic, - rotation_center = :lookat, - update_rate = 1/30, - projectiontype = Perspective, - fixed_axis = true, - zoom_shift_lookat = false, # doesn't really work with fov - cad = false, - # internal - selected = true - ) + overwrites = Attributes(kwargs) + + controls = Attributes( + # Keyboard controls + # Translations + up_key = Keyboard.r, + down_key = Keyboard.f, + left_key = Keyboard.a, + right_key = Keyboard.d, + forward_key = Keyboard.w, + backward_key = Keyboard.s, + # Zooms + zoom_in_key = Keyboard.u, + zoom_out_key = Keyboard.o, + increase_fov_key = Keyboard.b, + decrease_fov_key = Keyboard.n, + # Rotations + pan_left_key = Keyboard.j, + pan_right_key = Keyboard.l, + tilt_up_key = Keyboard.i, + tilt_down_key = Keyboard.k, + roll_clockwise_key = Keyboard.e, + roll_counterclockwise_key = Keyboard.q, + # Mouse controls + translation_button = Mouse.right, + rotation_button = Mouse.left, + scroll_mod = true, + reposition_button = Keyboard.left_alt & Mouse.left, + # Shared controls + fix_x_key = Keyboard.x, + fix_y_key = Keyboard.y, + fix_z_key = Keyboard.z, + reset = Keyboard.left_control & Mouse.left + ) + + replace!(controls, :Camera3D, scene, overwrites) + + settings = Attributes( + keyboard_rotationspeed = 1f0, + keyboard_translationspeed = 0.5f0, + keyboard_zoomspeed = 1f0, + + mouse_rotationspeed = 1f0, + mouse_translationspeed = 1f0, + mouse_zoomspeed = 1f0, + + projectiontype = Makie.Perspective, + circular_rotation = (true, true, true), + rotation_center = :lookat, + update_rate = 1/30, + zoom_shift_lookat = true, + fixed_axis = true, + cad = false, + center = true, + clipping_mode = :adaptive # TODO: use bbox to adjust near/far automatically + ) + + replace!(settings, :Camera3D, scene, overwrites) + + if settings.clipping_mode[] === :view_relative + far_default = 2f0 + elseif settings.clipping_mode[] === :bbox_relative + far_default = 1f0 + else + far_default = 100f0 # will be set when inserting a plot end cam = Camera3D( - pop!(attr, :eyeposition, Vec3f(3)), - pop!(attr, :lookat, Vec3f(0)), - pop!(attr, :upvector, Vec3f(0, 0, 1)), - - Observable(1f0), - Observable(attr[:fov][]), - Observable(attr[:near][] === automatic ? 0.1f0 : attr[:near][]), - Observable(attr[:far][] === automatic ? 100f0 : attr[:far][]), - Observable(-1.0), + settings, controls, - attr + # Internals - controls + Observable(-1.0), + Observable(true), + + # Semi-Internal - view matrix + get(overwrites, :eyeposition, Observable(Vec3f(3, 3, 3))), + get(overwrites, :lookat, Observable(Vec3f(0, 0, 0))), + get(overwrites, :upvector, Observable(Vec3f(0, 0, 1))), + + # Semi-Internal - projection matrix + get(overwrites, :fov, Observable(45.0)), + get(overwrites, :near, Observable(0.1)), + get(overwrites, :far, Observable(far_default)), + Sphere(Point3f(0), 1f0) ) disconnect!(camera(scene)) @@ -154,9 +206,9 @@ function Camera3D(scene::Scene; kwargs...) # ticks every so often to get consistent position updates. on(cam.pulser) do prev_time current_time = time() - active = on_pulse(scene, cam, Float32(current_time - prev_time)) - @async if active && attr.selected[] - sleep(attr.update_rate[]) + active = on_pulse(scene, cam, current_time - prev_time) + @async if active && cam.selected[] + sleep(settings.update_rate[]) cam.pulser[] = current_time else cam.pulser.val = -1.0 @@ -165,7 +217,7 @@ function Camera3D(scene::Scene; kwargs...) keynames = ( :up_key, :down_key, :left_key, :right_key, :forward_key, :backward_key, - :zoom_in_key, :zoom_out_key, :stretch_view_key, :contract_view_key, + :zoom_in_key, :zoom_out_key, :increase_fov_key, :decrease_fov_key, :pan_left_key, :pan_right_key, :tilt_up_key, :tilt_down_key, :roll_clockwise_key, :roll_counterclockwise_key ) @@ -173,7 +225,7 @@ function Camera3D(scene::Scene; kwargs...) # Start ticking if relevant keys are pressed on(camera(scene), events(scene).keyboardbutton) do event if event.action in (Keyboard.press, Keyboard.repeat) && cam.pulser[] == -1.0 && - attr.selected[] && any(key -> ispressed(scene, attr[key][]), keynames) + cam.selected[] && any(key -> ispressed(scene, controls[key][]), keynames) cam.pulser[] = time() return Consume(true) end @@ -185,56 +237,70 @@ function Camera3D(scene::Scene; kwargs...) deselect_all_cameras!(root(scene)) on(camera(scene), events(scene).mousebutton, priority = 100) do event if event.action == Mouse.press - attr.selected[] = is_mouseinside(scene) + cam.selected[] = is_mouseinside(scene) end return Consume(false) end # Mouse controls - add_translation!(scene, cam) - add_rotation!(scene, cam) + add_mouse_controls!(scene, cam) # add camera controls to scene cameracontrols!(scene, cam) # Trigger updates on scene resize and settings change - on(camera(scene), scene.px_area, attr[:fov], attr[:projectiontype]) do _, _, _ - update_cam!(scene, cam) + on(camera(scene), cam.fov) do _ + if settings.projectiontype[] == Makie.Perspective + update_cam!(scene, cam) + end end - on(camera(scene), attr[:near], attr[:far]) do near, far - near === automatic || (cam.near[] = near) - far === automatic || (cam.far[] = far) + on(camera(scene), scene.viewport, cam.near, cam.far, settings.projectiontype) do _, _, _, _ update_cam!(scene, cam) end # reset - on(camera(scene), events(scene).keyboardbutton) do event - if attr.selected[] && event.key == attr[:reset][] && event.action == Keyboard.release + on(camera(scene), events(scene).keyboardbutton, events(scene).mousebutton, priority = 1) do ke, me + if cam.selected[] && ispressed(scene, controls[:reset][]) && + (ke.action == Keyboard.press || me.action == Mouse.press) # center keeps the rotation of the camera so we reset that here # might make sense to keep user set lookat, upvector, eyeposition # around somewhere for this? - cam.lookat[] = Vec3f(0) - cam.upvector[] = Vec3f(0,0,1) - cam.eyeposition[] = Vec3f(3) + old_center = cam.settings.center[] + cam.settings.center[] = true center!(scene) + cam.settings.center[] = old_center return Consume(true) end return Consume(false) end + update_cam!(scene, cam) + cam end # These imitate the old camera +""" + cam3d!(scene[; kwargs...]) + +Creates a `Camera3D` with `zoom_shift_lookat = true` and `fixed_axis = true`. +For more information, see [`Camera3D`](@ref) +""" cam3d!(scene; zoom_shift_lookat = true, fixed_axis = true, kwargs...) = Camera3D(scene, zoom_shift_lookat = zoom_shift_lookat, fixed_axis = fixed_axis; kwargs...) +""" + cam3d_cad!(scene[; kwargs...]) + +Creates a `Camera3D` with `cad = true`, `zoom_shift_lookat = false` and +`fixed_axis = false`. For more information, see [`Camera3D`](@ref) +""" cam3d_cad!(scene; cad = true, zoom_shift_lookat = false, fixed_axis = false, kwargs...) = Camera3D(scene, cad = cad, zoom_shift_lookat = zoom_shift_lookat, fixed_axis = fixed_axis; kwargs...) function deselect_all_cameras!(scene) cam = cameracontrols(scene) - cam isa Camera3D && (cam.attributes.selected[] = false) + cam isa AbstractCamera3D && (cam.selected[] = false) for child in scene.children deselect_all_cameras!(child) end @@ -242,101 +308,187 @@ function deselect_all_cameras!(scene) end -function add_translation!(scene, cam::Camera3D) - translationspeed = cam.attributes[:mouse_translationspeed] - zoomspeed = cam.attributes[:mouse_zoomspeed] - shift_lookat = cam.attributes[:zoom_shift_lookat] - cad = cam.attributes[:cad] - button = cam.attributes[:translation_button] - scroll_mod = cam.attributes[:scroll_mod] +################################################################################ +### Interactivity init +################################################################################ - last_mousepos = RefValue(Vec2f(0, 0)) - dragging = RefValue(false) - function compute_diff(delta) - if cam.attributes[:projectiontype][] == Orthographic - aspect = Float32((/)(widths(scene.px_area[])...)) - aspect_scale = Vec2f(1f0 + aspect, 1f0 + 1f0 / aspect) - return cam.zoom_mult[] * delta .* aspect_scale ./ widths(scene.px_area[]) + +function on_pulse(scene, cam::Camera3D, timestep) + @extractvalue cam.controls ( + right_key, left_key, up_key, down_key, backward_key, forward_key, + tilt_up_key, tilt_down_key, pan_left_key, pan_right_key, roll_counterclockwise_key, roll_clockwise_key, + zoom_out_key, zoom_in_key, increase_fov_key, decrease_fov_key + ) + @extractvalue cam.settings ( + keyboard_translationspeed, keyboard_rotationspeed, keyboard_zoomspeed, projectiontype + ) + + # translation + right = ispressed(scene, right_key) + left = ispressed(scene, left_key) + up = ispressed(scene, up_key) + down = ispressed(scene, down_key) + backward = ispressed(scene, backward_key) + forward = ispressed(scene, forward_key) + translating = right || left || up || down || backward || forward + + if translating + # translation in camera space x/y/z direction + if projectiontype == Perspective + viewnorm = norm(cam.lookat[] - cam.eyeposition[]) + xynorm = 2 * viewnorm * tand(0.5 * cam.fov[]) + translation = keyboard_translationspeed * timestep * Vec3f( + xynorm * (right - left), + xynorm * (up - down), + viewnorm * (backward - forward) + ) else - viewdir = cam.lookat[] - cam.eyeposition[] - return 0.002f0 * cam.zoom_mult[] * norm(viewdir) * delta + # translation in camera space x/y/z direction + viewnorm = norm(cam.eyeposition[] - cam.lookat[]) + translation = 2 * viewnorm * keyboard_translationspeed * timestep * Vec3f( + right - left, up - down, backward - forward + ) end + _translate_cam!(scene, cam, translation) end - # drag start/stop - on(camera(scene), scene.events.mousebutton) do event - if ispressed(scene, button[]) - if event.action == Mouse.press && is_mouseinside(scene) && !dragging[] - last_mousepos[] = mouseposition_px(scene) - dragging[] = true - return Consume(true) - end - elseif event.action == Mouse.release && dragging[] - mousepos = mouseposition_px(scene) - diff = compute_diff(last_mousepos[] .- mousepos) - last_mousepos[] = mousepos - dragging[] = false - translate_cam!(scene, cam, translationspeed[] .* Vec3f(diff[1], diff[2], 0f0)) - return Consume(true) - end - return Consume(false) + # rotation + up = ispressed(scene, tilt_up_key) + down = ispressed(scene, tilt_down_key) + left = ispressed(scene, pan_left_key) + right = ispressed(scene, pan_right_key) + counterclockwise = ispressed(scene, roll_counterclockwise_key) + clockwise = ispressed(scene, roll_clockwise_key) + rotating = up || down || left || right || counterclockwise || clockwise + + if rotating + # rotations around camera space x/y/z axes + angles = keyboard_rotationspeed * timestep * + Vec3f(up - down, left - right, counterclockwise - clockwise) + + _rotate_cam!(scene, cam, angles) end - # in drag - on(camera(scene), scene.events.mouseposition) do mp - if dragging[] && ispressed(scene, button[]) - mousepos = screen_relative(scene, mp) - diff = compute_diff(last_mousepos[] .- mousepos) - last_mousepos[] = mousepos - translate_cam!(scene, cam, translationspeed[] * Vec3f(diff[1], diff[2], 0f0)) - return Consume(true) - end - return Consume(false) + # zoom + zoom_out = ispressed(scene, zoom_out_key) + zoom_in = ispressed(scene, zoom_in_key) + zooming = zoom_out || zoom_in + + if zooming + zoom_step = (1f0 + keyboard_zoomspeed * timestep) ^ (zoom_out - zoom_in) + _zoom!(scene, cam, zoom_step, false, false) end - on(camera(scene), scene.events.scroll) do scroll - if is_mouseinside(scene) && ispressed(scene, scroll_mod[]) - zoom_step = (1f0 + 0.1f0 * zoomspeed[]) ^ -scroll[2] - zoom!(scene, cam, zoom_step, shift_lookat[], cad[]) - return Consume(true) - end - return Consume(false) + # fov + fov_inc = ispressed(scene, increase_fov_key) + fov_dec = ispressed(scene, decrease_fov_key) + fov_adjustment = fov_inc || fov_dec + + if fov_adjustment + step = (1 + keyboard_zoomspeed * timestep) ^ (fov_inc - fov_dec) + cam.fov[] = clamp(cam.fov[] * step, 0.1, 179) + end + + # if any are active, update matrices, else stop clock + if translating || rotating || zooming || fov_adjustment + update_cam!(scene, cam) + return true + else + return false end end -function add_rotation!(scene, cam::Camera3D) - rotationspeed = cam.attributes[:mouse_rotationspeed] - button = cam.attributes[:rotation_button] + +function add_mouse_controls!(scene, cam::Camera3D) + @extract cam.controls (translation_button, rotation_button, reposition_button, scroll_mod) + @extract cam.settings ( + mouse_translationspeed, mouse_rotationspeed, mouse_zoomspeed, + cad, projectiontype, zoom_shift_lookat + ) + last_mousepos = RefValue(Vec2f(0, 0)) - dragging = RefValue(false) + dragging = RefValue((false, false)) # rotation, translation + e = events(scene) + function compute_diff(delta) + if projectiontype[] == Perspective + # TODO wrong scaling? :( + ynorm = 2 * norm(cam.lookat[] - cam.eyeposition[]) * tand(0.5 * cam.fov[]) + return ynorm / size(scene, 2) * delta + else + viewnorm = norm(cam.eyeposition[] - cam.lookat[]) + return 2 * viewnorm / size(scene, 2) * delta + end + end + # drag start/stop on(camera(scene), e.mousebutton) do event - if ispressed(scene, button[]) - if event.action == Mouse.press && is_mouseinside(scene) && !dragging[] + # Drag start translation/rotation + if event.action == Mouse.press && is_mouseinside(scene) + if ispressed(scene, translation_button[]) + last_mousepos[] = mouseposition_px(scene) + dragging[] = (false, true) + return Consume(true) + elseif ispressed(scene, rotation_button[]) last_mousepos[] = mouseposition_px(scene) - dragging[] = true + dragging[] = (true, false) return Consume(true) end - elseif event.action == Mouse.release && dragging[] - mousepos = mouseposition_px(scene) - dragging[] = false - rot_scaling = rotationspeed[] * (e.window_dpi[] * 0.005) - mp = (last_mousepos[] .- mousepos) .* 0.01f0 .* rot_scaling - last_mousepos[] = mousepos - rotate_cam!(scene, cam, Vec3f(-mp[2], mp[1], 0f0), true) - return Consume(true) + # drag stop & repostion + elseif event.action == Mouse.release + consume = false + + # Drag stop translation/rotation + if dragging[][1] + mousepos = mouseposition_px(scene) + diff = compute_diff(last_mousepos[] .- mousepos) + last_mousepos[] = mousepos + dragging[] = (false, false) + translate_cam!(scene, cam, mouse_translationspeed[] .* Vec3f(diff[1], diff[2], 0f0)) + consume = true + elseif dragging[][2] + mousepos = mouseposition_px(scene) + dragging[] = (false, false) + rot_scaling = mouse_rotationspeed[] * (e.window_dpi[] * 0.005) + mp = (last_mousepos[] .- mousepos) .* 0.01f0 .* rot_scaling + last_mousepos[] = mousepos + rotate_cam!(scene, cam, Vec3f(-mp[2], mp[1], 0f0), true) + consume = true + end + + # reposition + if ispressed(scene, reposition_button[], event.button) && is_mouseinside(scene) + plt, _, p = ray_assisted_pick(scene) + p3d = to_ndim(Point3f, p, 0f0) + if !isnan(p3d) && to_value(get(plt, :space, :data)) == :data && parent_scene(plt) == scene + # if translation/rotation happens with on-click reposition, + # try uncommenting this + # dragging[] = (false, false) + shift = p3d - cam.lookat[] + update_cam!(scene, cam, cam.eyeposition[] + shift, p3d) + end + consume = true + end + + return Consume(consume) end + return Consume(false) end # in drag on(camera(scene), e.mouseposition) do mp - if dragging[] && ispressed(scene, button[]) + if dragging[][2] && ispressed(scene, translation_button[]) + mousepos = screen_relative(scene, mp) + diff = compute_diff(last_mousepos[] .- mousepos) + last_mousepos[] = mousepos + translate_cam!(scene, cam, mouse_translationspeed[] * Vec3f(diff[1], diff[2], 0f0)) + return Consume(true) + elseif dragging[][1] && ispressed(scene, rotation_button[]) mousepos = screen_relative(scene, mp) - rot_scaling = rotationspeed[] * (e.window_dpi[] * 0.005) + rot_scaling = mouse_rotationspeed[] * (e.window_dpi[] * 0.005) mp = (last_mousepos[] .- mousepos) * 0.01f0 * rot_scaling last_mousepos[] = mousepos rotate_cam!(scene, cam, Vec3f(-mp[2], mp[1], 0f0), true) @@ -344,80 +496,82 @@ function add_rotation!(scene, cam::Camera3D) end return Consume(false) end -end + #zoom + on(camera(scene), e.scroll) do scroll + if is_mouseinside(scene) && ispressed(scene, scroll_mod[]) + zoom_step = (1f0 + 0.1f0 * mouse_zoomspeed[]) ^ -scroll[2] + zoom!(scene, cam, zoom_step, cad[], zoom_shift_lookat[]) + return Consume(true) + end + return Consume(false) + end -function on_pulse(scene, cam, timestep) - attr = cam.attributes - # translation - right = ispressed(scene, attr[:right_key][]) - left = ispressed(scene, attr[:left_key][]) - up = ispressed(scene, attr[:up_key][]) - down = ispressed(scene, attr[:down_key][]) - backward = ispressed(scene, attr[:backward_key][]) - forward = ispressed(scene, attr[:forward_key][]) - translating = right || left || up || down || backward || forward +end - if translating - # translation in camera space x/y/z direction - translation = attr[:keyboard_translationspeed][] * timestep * - Vec3f(right - left, up - down, backward - forward) - viewdir = cam.lookat[] - cam.eyeposition[] - _translate_cam!(scene, cam, cam.zoom_mult[] * norm(viewdir) * translation) - end - # rotation - up = ispressed(scene, attr[:tilt_up_key][]) - down = ispressed(scene, attr[:tilt_down_key][]) - left = ispressed(scene, attr[:pan_left_key][]) - right = ispressed(scene, attr[:pan_right_key][]) - counterclockwise = ispressed(scene, attr[:roll_counterclockwise_key][]) - clockwise = ispressed(scene, attr[:roll_clockwise_key][]) - rotating = up || down || left || right || counterclockwise || clockwise +################################################################################ +### Camera transformations +################################################################################ - if rotating - # rotations around camera space x/y/z axes - angles = attr[:keyboard_rotationspeed][] * timestep * - Vec3f(up - down, left - right, counterclockwise - clockwise) - _rotate_cam!(scene, cam, angles) - end +# Simplified methods +""" + translate_cam!(scene, cam::Camera3D, v::Vec3) - # zoom - zoom_out = ispressed(scene, attr[:zoom_out_key][]) - zoom_in = ispressed(scene, attr[:zoom_in_key][]) - zooming = zoom_out || zoom_in +Translates the camera by the given vector in camera space, i.e. by `v[1]` to +the right, `v[2]` to the top and `v[3]` forward. - if zooming - zoom_step = (1f0 + attr[:keyboard_zoomspeed][] * timestep) ^ (zoom_out - zoom_in) - _zoom!(scene, cam, zoom_step, false) - end +Note that this method reacts to `fix_x_key` etc. If any of those keys are +pressed the translation will be restricted to act in these directions. +""" +function translate_cam!(scene, cam::Camera3D, t::VecTypes) + _translate_cam!(scene, cam, t) + update_cam!(scene, cam) + nothing +end - stretch = ispressed(scene, attr[:stretch_view_key][]) - contract = ispressed(scene, attr[:contract_view_key][]) - if stretch || contract - zoom_step = (1f0 + attr[:keyboard_zoomspeed][] * timestep) ^ (stretch - contract) - cam.eyeposition[] = cam.lookat[] + zoom_step * (cam.eyeposition[] - cam.lookat[]) - end - zooming = zooming || stretch || contract +""" + rotate_cam!(scene, cam::Camera3D, angles::Vec3) - # if any are active, update matrices, else stop clock - if translating || rotating || zooming - update_cam!(scene, cam) - return true - else - return false - end +Rotates the camera by the given `angles` around the camera x- (left, right), +y- (up, down) and z-axis (in out). The rotation around the y axis is applied +first, then x, then y. + +Note that this method reacts to `fix_x_key` etc and `fixed_axis`. The former +restrict the rotation around a specific axis when a given key is pressed. The +latter keeps the camera y axis fixed as the data space z axis. +""" +function rotate_cam!(scene, cam::Camera3D, angles::VecTypes, from_mouse=false) + _rotate_cam!(scene, cam, angles, from_mouse) + update_cam!(scene, cam) + nothing end -function translate_cam!(scene::Scene, cam::Camera3D, t::VecTypes) - _translate_cam!(scene, cam, t) +zoom!(scene, zoom_step) = zoom!(scene, cameracontrols(scene), zoom_step, false, false) +""" + zoom!(scene, cam::Camera3D, zoom_step[, cad = false, zoom_shift_lookat = false]) + +Zooms the camera in or out based on the multiplier `zoom_step`. A `zoom_step` +of 1.0 is neutral, larger zooms out and lower zooms in. + +If `cad = true` zooming will also apply a rotation based on how far the cursor +is from the center of the scene. If `zoom_shift_lookat = true` and +`projectiontype = Orthographic` zooming will keep the data under the cursor at +the same screen space position. +""" +function zoom!(scene, cam::Camera3D, zoom_step, cad = false, zoom_shift_lookat = false) + _zoom!(scene, cam, zoom_step, cad, zoom_shift_lookat) update_cam!(scene, cam) nothing end -function _translate_cam!(scene, cam, t) + + +function _translate_cam!(scene, cam::Camera3D, t) + @extractvalue cam.controls (fix_x_key, fix_y_key, fix_z_key) + # This uses a camera based coordinate system where # x expands right, y expands up and z expands towards the screen lookat = cam.lookat[] @@ -430,9 +584,9 @@ function _translate_cam!(scene, cam, t) trans = u_x * t[1] + u_y * t[2] + u_z * t[3] # apply world space restrictions - fix_x = ispressed(scene, cam.attributes[:fix_x_key][]) - fix_y = ispressed(scene, cam.attributes[:fix_y_key][]) - fix_z = ispressed(scene, cam.attributes[:fix_z_key][]) + fix_x = ispressed(scene, fix_x_key)::Bool + fix_y = ispressed(scene, fix_y_key)::Bool + fix_z = ispressed(scene, fix_z_key)::Bool if fix_x || fix_y || fix_z trans = Vec3f(fix_x, fix_y, fix_z) .* trans end @@ -443,12 +597,10 @@ function _translate_cam!(scene, cam, t) end -function rotate_cam!(scene, cam::Camera3D, angles::VecTypes, from_mouse=false) - _rotate_cam!(scene, cam, angles, from_mouse) - update_cam!(scene, cam) - nothing -end function _rotate_cam!(scene, cam::Camera3D, angles::VecTypes, from_mouse=false) + @extractvalue cam.controls (fix_x_key, fix_y_key, fix_z_key) + @extractvalue cam.settings (fixed_axis, circular_rotation, rotation_center) + # This applies rotations around the x/y/z axis of the camera coordinate system # x expands right, y expands up and z expands towards the screen lookat = cam.lookat[] @@ -458,32 +610,35 @@ function _rotate_cam!(scene, cam::Camera3D, angles::VecTypes, from_mouse=false) right = cross(viewdir, up) # +x x_axis = right - y_axis = cam.attributes[:fixed_axis][] ? Vec3f(0, 0, ifelse(up[3] < 0, -1, 1)) : up + y_axis = fixed_axis ? Vec3f(0, 0, ifelse(up[3] < 0, -1, 1)) : up z_axis = -viewdir - fix_x = ispressed(scene, cam.attributes[:fix_x_key][]) - fix_y = ispressed(scene, cam.attributes[:fix_y_key][]) - fix_z = ispressed(scene, cam.attributes[:fix_z_key][]) - cx, cy, cz = cam.attributes[:circular_rotation][] + fix_x = ispressed(scene, fix_x_key)::Bool + fix_y = ispressed(scene, fix_y_key)::Bool + fix_z = ispressed(scene, fix_z_key)::Bool + cx, cy, cz = circular_rotation + rotation = Quaternionf(0, 0, 0, 1) if !xor(fix_x, fix_y, fix_z) # if there are more or less than one restriction apply all rotations + # Note that the y rotation needs to happen first here so that + # fixed_axis = true actually keeps the the axis fixed. rotation *= qrotation(y_axis, angles[2]) rotation *= qrotation(x_axis, angles[1]) rotation *= qrotation(z_axis, angles[3]) else # apply world space restrictions - if from_mouse && ((fix_x && (fix_x == cx)) || (fix_y && (fix_y == cy)) || (fix_z && (fix_z == cz))) + if from_mouse && ((fix_x && cx) || (fix_y && cy) || (fix_z && cz)) # recontextualize the (dy, dx, 0) from mouse rotations so that # drawing circles creates continuous rotations around the fixed axis mp = mouseposition_px(scene) - past_half = 0.5f0 .* widths(scene.px_area[]) .> mp + past_half = 0.5f0 .* size(scene) .> mp flip = 2f0 * past_half .- 1f0 angle = flip[1] * angles[1] + flip[2] * angles[2] - angles = Vec3f(-angle, angle, -angle) + angles = Vec3f(-angle, -angle, angle) # only one fix is true so this only rotates around one axis rotation *= qrotation( - Vec3f(fix_x, fix_z, fix_y) .* Vec3f(sign(right[1]), viewdir[2], sign(up[3])), + Vec3f(fix_x, fix_y, fix_z) .* Vec3f(sign(right[1]), viewdir[2], sign(up[3])), dot(Vec3f(fix_x, fix_y, fix_z), angles) ) else @@ -501,138 +656,173 @@ function _rotate_cam!(scene, cam::Camera3D, angles::VecTypes, from_mouse=false) # TODO maybe generalize this to arbitrary center? # calculate positions from rotated vectors - if cam.attributes[:rotation_center][] === :lookat + if rotation_center === :lookat cam.eyeposition[] = lookat - viewdir else cam.lookat[] = eyepos + viewdir end + return end -""" - zoom!(scene, zoom_step) - -Zooms the camera in or out based on the multiplier `zoom_step`. A `zoom_step` -of 1.0 is neutral, larger zooms out and lower zooms in. - -Note that this method only applies to Camera3D. -""" -zoom!(scene::Scene, zoom_step) = zoom!(scene, cameracontrols(scene), zoom_step, false, false) -function zoom!(scene::Scene, cam::Camera3D, zoom_step, shift_lookat = false, cad = false) - _zoom!(scene, cam, zoom_step, shift_lookat, cad) - update_cam!(scene, cam) - nothing -end -function _zoom!(scene::Scene, cam::Camera3D, zoom_step, shift_lookat = false, cad = false) +function _zoom!(scene, cam::Camera3D, zoom_step, cad = false, zoom_shift_lookat = false) + lookat = cam.lookat[] + eyepos = cam.eyeposition[] + viewdir = lookat - eyepos # -z + vp = viewport(scene)[] + scene_width = widths(vp) if cad - # move exeposition if mouse is not over the center - lookat = cam.lookat[] - eyepos = cam.eyeposition[] - up = cam.upvector[] # +y - viewdir = lookat - eyepos # -z - right = cross(viewdir, up) # +x - - rel_pos = 2f0 * mouseposition_px(scene) ./ widths(scene.px_area[]) .- 1f0 - shift = rel_pos[1] * normalize(right) + rel_pos[2] * normalize(up) - shifted = eyepos + 0.1f0 * sign(1f0 - zoom_step) * norm(viewdir) * shift - cam.eyeposition[] = lookat + norm(viewdir) * normalize(shifted - lookat) - elseif shift_lookat - lookat = cam.lookat[] - eyepos = cam.eyeposition[] - up = normalize(cam.upvector[]) - viewdir = lookat - eyepos - u_z = normalize(-viewdir) - u_x = normalize(cross(up, u_z)) - u_y = normalize(cross(u_z, u_x)) - - if cam.attributes[:projectiontype][] == Perspective - # translate both eyeposition and lookat to more or less keep data - # under the mouse in view - fov = cam.attributes[:fov][] - before = tan(clamp(cam.zoom_mult[] * fov, 0.01f0, 175f0) / 360f0 * Float32(pi)) - after = tan(clamp(cam.zoom_mult[] * zoom_step * fov, 0.01f0, 175f0) / 360f0 * Float32(pi)) - - aspect = Float32((/)(widths(scene.px_area[])...)) - rel_pos = 2f0 * mouseposition_px(scene) ./ widths(scene.px_area[]) .- 1f0 - shift = rel_pos[1] * u_x + rel_pos[2] * u_y - shift = -(after - before) * norm(viewdir) * normalize(aspect .* shift) + # Rotate view based on offset from center + u_z = normalize(viewdir) + u_x = normalize(cross(u_z, cam.upvector[])) + u_y = normalize(cross(u_x, u_z)) + + rel_pos = 2.0f0 * mouseposition_px(scene) ./ scene_width .- 1.0f0 + shift = rel_pos[1] * u_x + rel_pos[2] * u_y + shift *= 0.1 * sign(1 - zoom_step) * norm(viewdir) + + cam.eyeposition[] = lookat - zoom_step * viewdir + shift + elseif zoom_shift_lookat + # keep data under cursor + u_z = normalize(viewdir) + u_x = normalize(cross(u_z, cam.upvector[])) + u_y = normalize(cross(u_x, u_z)) + + rel_pos = (2.0 .* mouseposition_px(scene) .- scene_width) ./ scene_width[2] + shift = (1 - zoom_step) * (rel_pos[1] * u_x + rel_pos[2] * u_y) + + if cam.settings.projectiontype[] == Makie.Orthographic + scale = norm(viewdir) else - mx, my = 2f0 * mouseposition_px(scene) ./ widths(scene.px_area[]) .- 1f0 - aspect = Float32((/)(widths(scene.px_area[])...)) - w = 0.5f0 * (1f0 + aspect) * cam.zoom_mult[] - h = 0.5f0 * (1f0 + 1f0 / aspect) * cam.zoom_mult[] - shift = (1f0 - zoom_step) * (mx * w * u_x + my * h * u_y) + # With perspective projection depth scales shift, but there is no way + # to tell which depth the user may want to keep in view. So we just + # assume it's the same depth as "lookat". + scale = norm(viewdir) * tand(0.5 * cam.fov[]) end - cam.lookat[] = lookat + shift - cam.eyeposition[] = eyepos + shift + cam.lookat[] = lookat + scale * shift + cam.eyeposition[] = lookat - zoom_step * viewdir + scale * shift + else + # just zoom in/out + cam.eyeposition[] = lookat - zoom_step * viewdir end - # apply zoom - cam.zoom_mult[] = cam.zoom_mult[] * zoom_step - return end +################################################################################ +### update_cam! methods +################################################################################ + + +# Update camera matrices function update_cam!(scene::Scene, cam::Camera3D) - @extractvalue cam (lookat, eyeposition, upvector) + @extractvalue cam (lookat, eyeposition, upvector, near, far, fov, bounding_sphere) + + view = Makie.lookat(eyeposition, lookat, upvector) - near = cam.near[]; far = cam.far[] - aspect = Float32((/)(widths(scene.px_area[])...)) + if cam.settings.clipping_mode[] === :view_relative + view_dist = norm(eyeposition - lookat) + near = view_dist * near; far = view_dist * far + elseif cam.settings.clipping_mode[] === :bbox_relative + view_dist = norm(eyeposition - lookat) + center_dist = norm(eyeposition - origin(bounding_sphere)) + far_dist = center_dist + radius(bounding_sphere) + near = max(view_dist * near, center_dist - radius(bounding_sphere)) + far = far_dist * far + elseif cam.settings.clipping_mode[] === :adaptive + view_dist = norm(eyeposition - lookat) + near = view_dist * near; far = max(radius(bounding_sphere) / tand(0.5f0 * cam.fov[]), view_dist) * far + elseif cam.settings.clipping_mode[] !== :static + @error "clipping_mode = $(cam.settings.clipping_mode[]) not recognized, using :static." + end - if cam.attributes[:projectiontype][] == Perspective - fov = clamp(cam.zoom_mult[] * cam.attributes[:fov][], 0.01f0, 175f0) - cam.fov[] = fov + aspect = Float32((/)(widths(scene)...)) + if cam.settings.projectiontype[] == Makie.Perspective proj = perspectiveprojection(fov, aspect, near, far) else - w = 0.5f0 * (1f0 + aspect) * cam.zoom_mult[] - h = 0.5f0 * (1f0 + 1f0 / aspect) * cam.zoom_mult[] + h = norm(eyeposition - lookat); w = h * aspect proj = orthographicprojection(-w, w, -h, h, near, far) end - view = Makie.lookat(eyeposition, lookat, upvector) - set_proj_view!(camera(scene), proj, view) scene.camera.eyeposition[] = cam.eyeposition[] + scene.camera.lookat[] = cam.lookat[] end -function update_cam!(scene::Scene, camera::Camera3D, area3d::Rect) - @extractvalue camera (lookat, eyeposition, upvector) + +# Update camera position via bbox +function update_cam!(scene::Scene, cam::Camera3D, area3d::Rect, recenter::Bool = cam.settings.center[]) bb = Rect3f(area3d) width = widths(bb) - half_width = width ./ 2f0 - middle = maximum(bb) - half_width - old_dir = normalize(eyeposition .- lookat) - camera.lookat[] = middle - neweyepos = middle .+ (1.2*norm(width) .* old_dir) - camera.eyeposition[] = neweyepos - camera.upvector[] = Vec3f(0,0,1) - if camera.attributes[:near][] === automatic - camera.near[] = 0.1f0 * norm(widths(bb)) + center = maximum(bb) - 0.5f0 * width + radius = 0.5f0 * norm(width) + (isnan(radius) || (radius == 0)) && return + cam.bounding_sphere[] = Sphere(Point3f(center), radius) + + old_dir = normalize(cam.eyeposition[] .- cam.lookat[]) + if cam.settings.projectiontype[] == Makie.Perspective + dist = radius / tand(0.5f0 * cam.fov[]) + else + dist = radius end - if camera.attributes[:far][] === automatic - camera.far[] = 3f0 * norm(widths(bb)) + + if recenter + cam.lookat[] = center + cam.eyeposition[] = cam.lookat[] .+ dist * old_dir + cam.upvector[] = normalize(cross(old_dir, cross(cam.upvector[], old_dir))) end - if camera.attributes[:projectiontype][] == Orthographic - camera.zoom_mult[] = 0.6 * norm(width) - else - camera.zoom_mult[] = 1f0 + + if cam.settings.clipping_mode[] === :static + cam.near[] = 0.1f0 * dist + cam.far[] = 2f0 * dist + elseif cam.settings.clipping_mode[] === :adaptive + cam.near[] = 0.1f0 + cam.far[] = 2f0 end - update_cam!(scene, camera) + + update_cam!(scene, cam) + return end -function update_cam!(scene::Scene, camera::Camera3D, eyeposition, lookat, up = Vec3f(0, 0, 1)) - camera.lookat[] = Vec3f(lookat) +# Update camera position via camera Position & Orientation +function update_cam!(scene::Scene, camera::Camera3D, eyeposition::VecTypes, lookat::VecTypes, up::VecTypes = camera.upvector[]) + camera.lookat[] = Vec3f(lookat) camera.eyeposition[] = Vec3f(eyeposition) - camera.upvector[] = Vec3f(up) + camera.upvector[] = Vec3f(up) + update_cam!(scene, camera) + return +end + +update_cam!(scene::Scene, args::Real...) = update_cam!(scene, cameracontrols(scene), args...) + +""" + update_cam!(scene, cam::Camera3D, ϕ, θ[, radius]) + +Set the camera position based on two angles `0 ≤ ϕ ≤ 2π` and `-pi/2 ≤ θ ≤ pi/2` +and an optional radius around the current `cam.lookat[]`. +""" +function update_cam!( + scene::Scene, camera::Camera3D, phi::Real, theta::Real, + radius::Real = norm(camera.eyeposition[] - camera.lookat[]), + center = camera.lookat[] + ) + st, ct = sincos(theta) + sp, cp = sincos(phi) + v = Vec3f(ct * cp, ct * sp, st) + u = Vec3f(-st * cp, -st * sp, ct) + camera.lookat[] = center + camera.eyeposition[] = center .+ radius * v + camera.upvector[] = u update_cam!(scene, camera) return end + function show_cam(scene) cam = cameracontrols(scene) println("cam=cameracontrols(scene)") diff --git a/src/camera/old_camera3d.jl b/src/camera/old_camera3d.jl index 2d5f424cccf..33d381d2e45 100644 --- a/src/camera/old_camera3d.jl +++ b/src/camera/old_camera3d.jl @@ -46,13 +46,15 @@ function old_cam3d_cad!(scene::Scene; kw_args...) add_translation!(scene, cam, cam.pan_button, cam.move_key, false) add_rotation!(scene, cam, cam.rotate_button, cam.move_key, false) cameracontrols!(scene, cam) - on(camera(scene), scene.px_area) do area + on(camera(scene), scene.viewport) do area # update cam when screen ratio changes update_cam!(scene, cam) end cam end +get_space(::OldCamera3D) = :data + """ old_cam3d_turntable!(scene; kw_args...) @@ -82,7 +84,7 @@ function old_cam3d_turntable!(scene::Scene; kw_args...) add_translation!(scene, cam, cam.pan_button, cam.move_key, true) add_rotation!(scene, cam, cam.rotate_button, cam.move_key, true) cameracontrols!(scene, cam) - on(camera(scene), scene.px_area) do area + on(camera(scene), scene.viewport) do area # update cam when screen ratio changes update_cam!(scene, cam) end @@ -96,7 +98,12 @@ An alias to [`old_cam3d_turntable!`](@ref). Creates a 3D camera for `scene`, which rotates around the plot's axis. """ -const old_cam3d! = old_cam3d_turntable! +old_cam3d!(scene::Scene; kwargs...) = old_cam3d_turntable!(scene; kwargs...) + +@deprecate old_cam3d! cam3d! +@deprecate old_cam3d_turntable! cam3d! +@deprecate old_cam3d_cad! cam3d_cad! + function projection_switch( wh::Rect2, @@ -171,7 +178,7 @@ function add_translation!(scene, cam, key, button, zoom_shift_lookat::Bool) on(camera(scene), scene.events.scroll) do scroll if ispressed(scene, button[]) && is_mouseinside(scene) - cam_res = Vec2f(widths(scene.px_area[])) + cam_res = Vec2f(widths(scene)) mouse_pos_normalized = mouseposition_px(scene) ./ cam_res mouse_pos_normalized = 2*mouse_pos_normalized .- 1f0 zoom_step = scroll[2] @@ -232,7 +239,7 @@ function translate_cam!(scene::Scene, cam::OldCamera3D, _translation::VecTypes) dir = eyeposition - lookat dir_len = norm(dir) - cam_res = Vec2f(widths(scene.px_area[])) + cam_res = Vec2f(widths(scene)) z, x, y = translation z *= 0.1f0 * dir_len @@ -323,7 +330,7 @@ function update_cam!(scene::Scene, cam::OldCamera3D) # TODO this means you can't set FarClip... SAD! # TODO use boundingbox(scene) for optimal far/near far = max(zoom * 5f0, 30f0) - proj = projection_switch(scene.px_area[], fov, near, far, projectiontype, zoom) + proj = projection_switch(scene.viewport[], fov, near, far, projectiontype, zoom) view = Makie.lookat(eyeposition, lookat, upvector) set_proj_view!(camera(scene), proj, view) scene.camera.eyeposition[] = cam.eyeposition[] @@ -351,7 +358,9 @@ end Updates the camera's controls to point to the specified location. """ -update_cam!(scene::Scene, eyeposition, lookat, up = Vec3f(0, 0, 1)) = update_cam!(scene, cameracontrols(scene), eyeposition, lookat, up) +function update_cam!(scene::Scene, eyeposition::VecTypes{3}, lookat::VecTypes{3}, up::VecTypes{3} = Vec3f(0, 0, 1)) + return update_cam!(scene, cameracontrols(scene), eyeposition, lookat, up) +end function update_cam!(scene::Scene, camera::OldCamera3D, eyeposition, lookat, up = Vec3f(0, 0, 1)) camera.lookat[] = Vec3f(lookat) diff --git a/src/camera/projection_math.jl b/src/camera/projection_math.jl index 717224c6a6a..eada9918c2e 100644 --- a/src/camera/projection_math.jl +++ b/src/camera/projection_math.jl @@ -245,7 +245,7 @@ function to_world(scene::Scene, point::T) where T <: StaticVector inv(transformationmatrix(scene)[]) * inv(cam.view[]) * inv(cam.projection[]), - T(widths(pixelarea(scene)[])) + T(size(scene)) ) Point2f(x[1], x[2]) end @@ -280,7 +280,7 @@ end function project(scene::Scene, point::T) where T<:StaticVector cam = scene.camera - area = pixelarea(scene)[] + area = viewport(scene)[] # TODO, I think we need .+ minimum(area) # Which would be semi breaking at this point though, I suppose return project( @@ -344,6 +344,22 @@ function clip_to_space(cam::Camera, space::Symbol) end end +function get_space(scene::Scene) + space = get_space(cameracontrols(scene))::Symbol + space === :data ? (:data,) : (:data, space) +end +get_space(::AbstractCamera) = :data +# TODO: Should this be less specialized? ScenePlot? AbstractPlot? +get_space(plot::Plot) = to_value(get(plot, :space, :data))::Symbol + +is_space_compatible(a, b) = is_space_compatible(get_space(a), get_space(b)) +is_space_compatible(a::Symbol, b::Symbol) = a === b +is_space_compatible(a::Symbol, b::Union{Tuple, Vector}) = a in b +function is_space_compatible(a::Union{Tuple, Vector}, b::Union{Tuple, Vector}) + any(x -> is_space_compatible(x, b), a) +end +is_space_compatible(a::Union{Tuple, Vector}, b::Symbol) = is_space_compatible(b, a) + function project(cam::Camera, input_space::Symbol, output_space::Symbol, pos) input_space === output_space && return to_ndim(Point3f, pos, 0) clip_from_input = space_to_clip(cam, input_space) diff --git a/src/colorsampler.jl b/src/colorsampler.jl index 98b46b132ac..a8800b46b69 100644 --- a/src/colorsampler.jl +++ b/src/colorsampler.jl @@ -128,10 +128,14 @@ function sampler(cmap::Matrix{<: Colorant}, uv::AbstractVector{Vec2f}; return Sampler(cmap, uv, alpha, interpolation, Scaling()) end +apply_scale(scale::AbstractObservable, x) = lift(apply_scale, scale, x) +apply_scale(::Union{Nothing,typeof(identity)}, x) = x # noop +apply_scale(scale, x) = broadcast(scale, x) function numbers_to_colors(numbers::Union{AbstractArray{<:Number},Number}, primitive) colormap = get_attribute(primitive, :colormap)::Vector{RGBAf} _colorrange = get_attribute(primitive, :colorrange)::Union{Nothing, Vec2f} + colorscale = get_attribute(primitive, :colorscale) colorrange = if isnothing(_colorrange) # TODO, plot primitive should always expand automatic values numbers isa Number && error("Cannot determine a colorrange automatically for single number color value $numbers. Pass an explicit colorrange.") @@ -140,29 +144,229 @@ function numbers_to_colors(numbers::Union{AbstractArray{<:Number},Number}, primi _colorrange end - lowclip = get_attribute(primitive, :lowclip) - highclip = get_attribute(primitive, :highclip) - nan_color = get_attribute(primitive, :nan_color, RGBAf(0,0,0,0)) + lowclip = get_attribute(primitive, :lowclip)::RGBAf + highclip = get_attribute(primitive, :highclip)::RGBAf + nan_color = get_attribute(primitive, :nan_color, RGBAf(0,0,0,0))::RGBAf - return numbers_to_colors(numbers, colormap, colorrange, lowclip, highclip, nan_color) + return numbers_to_colors(numbers, colormap, colorscale, colorrange, lowclip, highclip, nan_color) end - -function numbers_to_colors(numbers::Union{AbstractArray{<:Number},Number}, colormap, colorrange::Vec2, - lowclip::Union{Nothing,RGBAf}, - highclip::Union{Nothing,RGBAf}, - nan_color::RGBAf)::Union{Vector{RGBAf},RGBAf} +function numbers_to_colors(numbers::Union{AbstractArray{<:Number, N},Number}, + colormap, colorscale, colorrange::Vec2, + lowclip::Union{Automatic,RGBAf}, + highclip::Union{Automatic,RGBAf}, + nan_color::RGBAf)::Union{Array{RGBAf, N},RGBAf} where {N} cmin, cmax = colorrange + scaled_cmin = apply_scale(colorscale, cmin) + scaled_cmax = apply_scale(colorscale, cmax) + return map(numbers) do number - if isnan(number) + scaled_number = apply_scale(colorscale, Float64(number)) # ints don't work in interpolated_getindex + if isnan(scaled_number) return nan_color - elseif !isnothing(lowclip) && number < cmin + elseif !isnothing(lowclip) && scaled_number < scaled_cmin return lowclip - elseif !isnothing(highclip) && number > cmax + elseif !isnothing(highclip) && scaled_number > scaled_cmax return highclip end - return interpolated_getindex(colormap, - Float64(number), # ints don't work in interpolated_getindex - (cmin, cmax)) + return interpolated_getindex( + colormap, + scaled_number, + (scaled_cmin, scaled_cmax)) end end + +""" + ColorMappingType + +* categorical: there are n categories, and n colors are assigned to each category +* banded: there are ranges edge_start..edge_end, inside which values are mapped to one color +* continous: colors are mapped continuously to values +""" +@enum ColorMappingType categorical banded continuous + + +struct ColorMapping{N,T<:AbstractArray{<:Number,N},T2<:AbstractArray{<:Number,N}} + # The pure color values from the plot this colormapping is associated to + # Will be always an array of numbers + color::Observable{T} + colormap::Observable{Vector{RGBAf}} + raw_colormap::Observable{Vector{RGBAf}} # the above is scaled (when coming from cgrad), this is not + + # Scaling function that gets applied to color + scale::Observable{Function} + + # The 0-1 scaled values from crange, which describe the colormapping + mapping::Observable{Union{Nothing, Vector{Float64}}} + colorrange::Observable{Vec{2,Float64}} + + lowclip::Observable{Union{Automatic, RGBAf}} # Defaults to first color in colormap + highclip::Observable{Union{Automatic, RGBAf}} # Defaults to last color in colormap + nan_color::Observable{RGBAf} + + color_mapping_type::Observable{ColorMappingType} + + # scaled attributes + colorrange_scaled::Observable{Vec2f} + color_scaled::Observable{T2} +end + +""" + Categorical(colormaplike) + +Accepts all colormap values that the `colormap` attribute of a plot accepts. +Will make sure to map one value to one color and create the correct Colorbar for the plot. + +Example: +```julia +fig, ax, pl = barplot(1:3; color=1:3, colormap=Makie.Categorical(:viridis)) +``` + +!!! warning + This feature might change outside breaking releases, since the API is not yet finalized +""" +struct Categorical{T} <: AbstractVector{RGBAf} + values::Vector{T} +end +Categorical(values) = Categorical(to_colormap(values)) +Base.getindex(c::Categorical, i) = c.values[i] +Base.size(c::Categorical) = size(c.values) + +_array_value_type(::Categorical{T}) where T = Vector{T} +_array_value_type(A::AbstractArray{<:Number}) = typeof(A) +_array_value_type(r::AbstractRange) = Vector{eltype(r)} # use vector instead, to have a few less types to worry about + +_to_colormap(x::PlotUtils.ColorGradient) = to_colormap(x.colors) +_to_colormap(x) = to_colormap(x) + + +colormapping_type(@nospecialize(colormap)) = continuous +colormapping_type(::PlotUtils.CategoricalColorGradient) = banded +colormapping_type(::Categorical) = categorical + + +function _colormapping( + color_tight::Observable{V}, + @nospecialize(colors_obs), + @nospecialize(colormap), + @nospecialize(colorrange), + @nospecialize(colorscale), + @nospecialize(alpha), + @nospecialize(lowclip), + @nospecialize(highclip), + @nospecialize(nan_color), + color_mapping_type) where {V <: AbstractArray{T, N}} where {N, T} + + map_colors = Observable(RGBAf[]; ignore_equal_values=true) + raw_colormap = Observable(RGBAf[]; ignore_equal_values=true) + mapping = Observable{Union{Nothing,Vector{Float64}}}(nothing; ignore_equal_values=true) + colorscale = convert(Observable{Function}, colorscale) + + function update_colors(cmap, a) + colors = to_colormap(cmap) + raw_colors = _to_colormap(cmap) # dont do the scaling from cgrad + if a < 1.0 + colors = map(c -> RGBAf(Colors.color(c), Colors.alpha(c) * a), colors) + raw_colors = map(c -> RGBAf(Colors.color(c), Colors.alpha(c) * a), raw_colors) + end + map_colors[] = colors + raw_colormap[] = raw_colors + if cmap isa PlotUtils.ColorGradient + mapping[] = cmap.values + end + return + end + + onany(update_colors, colormap, alpha) + update_colors(colormap[], alpha[]) + + _lowclip = Observable{Union{Automatic,RGBAf}}(automatic; ignore_equal_values=true) + on(lowclip; update=true) do lc + _lowclip[] = lc isa Union{Nothing,Automatic} ? automatic : to_color(lc) + return + end + _highclip = Observable{Union{Automatic,RGBAf}}(automatic; ignore_equal_values=true) + on(highclip; update=true) do hc + _highclip[] = hc isa Union{Nothing,Automatic} ? automatic : to_color(hc) + return + end + + colorrange = lift(color_tight, colorrange; ignore_equal_values=true) do color, crange + return crange isa Automatic ? Vec2{Float64}(distinct_extrema_nan(color)) : Vec2{Float64}(crange) + end + + colorrange_scaled = lift(colorrange, colorscale; ignore_equal_values=true) do range, scale + return Vec2f(apply_scale(scale, range)) + end + + color_scaled = lift(color_tight, colorscale) do color, scale + return el32convert(apply_scale(scale, color)) + end + CT = ColorMapping{N,V,typeof(color_scaled[])} + + return CT(color_tight, + map_colors, + raw_colormap, + colorscale, + mapping, + colorrange, + _lowclip, + _highclip, + lift(to_color, nan_color), + color_mapping_type, + colorrange_scaled, + color_scaled) +end + +function ColorMapping( + color::AbstractArray{<:Number, N}, + @nospecialize(colors_obs), + @nospecialize(colormap), + @nospecialize(colorrange), + @nospecialize(colorscale), + @nospecialize(alpha), + @nospecialize(lowclip), + @nospecialize(highclip), + @nospecialize(nan_color), + color_mapping_type=lift(colormapping_type, colormap; ignore_equal_values=true)) where {N} + + T = _array_value_type(color) + color_tight = convert(Observable{T}, colors_obs)::Observable{T} + _colormapping(color_tight, colors_obs, colormap, colorrange, + colorscale, alpha, lowclip, highclip, nan_color, color_mapping_type) +end + +function assemble_colors(c::AbstractArray{<:Number}, @nospecialize(color), @nospecialize(plot)) + return ColorMapping(c, color, plot.colormap, plot.colorrange, plot.colorscale, plot.alpha, plot.lowclip, + plot.highclip, plot.nan_color) +end + +function to_color(c::ColorMapping) + return numbers_to_colors(c.color_scaled[], c.colormap[], identity, c.colorrange_scaled[], lowclip(c)[], highclip(c)[], c.nan_color[]) +end + +function Base.get(c::ColorMapping, value::Number) + return numbers_to_colors([value], c.colormap[], c.scale[], c.colorrange_scaled[], lowclip(c)[], + highclip(c)[], c.nan_color[])[1] +end + +function assemble_colors(colortype, color, plot) + return lift(plot, color, plot.alpha) do color, a + if a < 1.0 + return broadcast(c-> RGBAf(Colors.color(c), Colors.alpha(c) * a), to_color(color)) + else + return to_color(color) + end + end +end + +function assemble_colors(::Number, color, plot) + plot.colorrange[] isa Automatic && error("Cannot determine a colorrange automatically for single number color value. Pass an explicit colorrange.") + + cm = assemble_colors([color[]], lift(x -> [x], color), plot) + return lift((args...)-> numbers_to_colors(args...)[1], cm.color_scaled, cm.colormap, identity, cm.colorrange_scaled, cm.lowclip, cm.highclip, + cm.nan_color) +end + +highclip(cmap::ColorMapping) = lift((cm, hc) -> hc isa Automatic ? last(cm) : hc, cmap.colormap, cmap.highclip) +lowclip(cmap::ColorMapping) = lift((cm, hc) -> hc isa Automatic ? first(cm) : hc, cmap.colormap, cmap.lowclip) diff --git a/src/conversions.jl b/src/conversions.jl index f2d10b43f93..4f75c9ba3e6 100644 --- a/src/conversions.jl +++ b/src/conversions.jl @@ -1,11 +1,11 @@ ################################################################################ # Type Conversions # ################################################################################ -const RangeLike = Union{AbstractRange, AbstractVector, ClosedInterval} +const RangeLike = Union{AbstractVector, ClosedInterval, Tuple{Any,Any}} # if no plot type based conversion is defined, we try using a trait function convert_arguments(T::PlotFunc, args...; kw...) - ct = conversion_trait(T) + ct = conversion_trait(T, args...) try convert_arguments(ct, args...; kw...) catch e @@ -41,7 +41,7 @@ end # and reconvert the whole tuple in order to handle missings centrally, e.g. function convert_arguments_individually(T::PlotFunc, args...) # convert each single argument until it doesn't change type anymore - single_converted = recursively_convert_argument.(args) + single_converted = map(recursively_convert_argument, args) # if the type of args hasn't changed this function call didn't help and we error if typeof(single_converted) == typeof(args) throw(MethodError(convert_arguments, (T, args...))) @@ -54,9 +54,9 @@ end function recursively_convert_argument(x) newx = convert_single_argument(x) if typeof(newx) == typeof(x) - x + return x else - recursively_convert_argument(newx) + return recursively_convert_argument(newx) end end @@ -109,9 +109,18 @@ end Enables to use scatter like a surface plot with x::Vector, y::Vector, z::Matrix spanning z over the grid spanned by x y """ -function convert_arguments(::PointBased, x::AbstractVector, y::AbstractVector, z::AbstractMatrix) +function convert_arguments(::PointBased, x::AbstractArray, y::AbstractVector, z::AbstractArray) (vec(Point3f.(x, y', z)),) end + +function convert_arguments(p::PointBased, x::AbstractInterval, y::AbstractInterval, z::AbstractMatrix) + return convert_arguments(p, to_linspace(x, size(z, 1)), to_linspace(y, size(z, 2)), z) +end + +function convert_arguments(::PointBased, x::AbstractArray, y::AbstractMatrix, z::AbstractArray) + (vec(Point3f.(x, y, z)),) +end + """ convert_arguments(P, x, y, z)::(Vector) @@ -154,7 +163,6 @@ from `x` and `y`. `P` is the plot Type (it is optional). """ -#convert_arguments(::PointBased, x::RealVector, y::RealVector) = (Point2f.(x, y),) convert_arguments(P::PointBased, x::ClosedInterval, y::RealVector) = convert_arguments(P, LinRange(extrema(x)..., length(y)), y) convert_arguments(P::PointBased, x::RealVector, y::ClosedInterval) = convert_arguments(P, x, LinRange(extrema(y)..., length(x))) @@ -302,7 +310,7 @@ end ################################################################################ -# SurfaceLike # +# GridBased # ################################################################################ function edges(v::AbstractVector) @@ -320,62 +328,76 @@ function edges(v::AbstractVector) end end -function adjust_axes(::DiscreteSurface, x::AbstractVector{<:Number}, y::AbstractVector{<:Number}, z::AbstractMatrix) +function adjust_axes(::CellGrid, x::AbstractVector{<:Number}, y::AbstractVector{<:Number}, z::AbstractMatrix) x̂, ŷ = map((x, y), size(z)) do v, sz return length(v) == sz ? edges(v) : v end return x̂, ŷ, z end -adjust_axes(::SurfaceLike, x, y, z) = x, y, z +adjust_axes(::VertexGrid, x, y, z) = x, y, z """ - convert_arguments(SL::SurfaceLike, x::VecOrMat, y::VecOrMat, z::Matrix) + convert_arguments(ct::GridBased, x::VecOrMat, y::VecOrMat, z::Matrix) -If `SL` is `Heatmap` and `x` and `y` are vectors, infer from length of `x` and `y` +If `ct` is `Heatmap` and `x` and `y` are vectors, infer from length of `x` and `y` whether they represent edges or centers of the heatmap bins. If they are centers, convert to edges. Convert eltypes to `Float32` and return outputs as a `Tuple`. """ -function convert_arguments(SL::SurfaceLike, x::AbstractVecOrMat{<: Number}, y::AbstractVecOrMat{<: Number}, z::AbstractMatrix{<: Union{Number, Colorant}}) - return map(el32convert, adjust_axes(SL, x, y, z)) +function convert_arguments(ct::GridBased, x::AbstractVecOrMat{<: Number}, y::AbstractVecOrMat{<: Number}, z::AbstractMatrix{<: Union{Number, Colorant}}) + return map(el32convert, adjust_axes(ct, x, y, z)) end -function convert_arguments(SL::SurfaceLike, x::AbstractVecOrMat{<: Number}, y::AbstractVecOrMat{<: Number}, z::AbstractMatrix{<:Number}) - return map(el32convert, adjust_axes(SL, x, y, z)) +function convert_arguments(ct::GridBased, x::AbstractVecOrMat{<: Number}, y::AbstractVecOrMat{<: Number}, z::AbstractMatrix{<:Number}) + return map(el32convert, adjust_axes(ct, x, y, z)) end -convert_arguments(sl::SurfaceLike, x::AbstractMatrix, y::AbstractMatrix) = convert_arguments(sl, x, y, zeros(size(y))) +convert_arguments(ct::VertexGrid, x::AbstractMatrix, y::AbstractMatrix) = convert_arguments(ct, x, y, zeros(size(y))) """ - convert_arguments(P, x, y, z)::Tuple{ClosedInterval, ClosedInterval, Matrix} + convert_arguments(P, x::RangeLike, y::RangeLike, z::AbstractMatrix) -Takes 2 ClosedIntervals's `x`, `y`, and an AbstractMatrix `z`, and converts the closed range to -linspaces with size(z, 1/2) -`P` is the plot Type (it is optional). +Takes one or two ClosedIntervals `x` and `y` and converts them to closed ranges +with size(z, 1/2). """ -function convert_arguments(P::SurfaceLike, x::ClosedInterval, y::ClosedInterval, z::AbstractMatrix) +function convert_arguments(P::GridBased, x::RangeLike, y::RangeLike, z::AbstractMatrix{<: Union{Number, Colorant}}) convert_arguments(P, to_linspace(x, size(z, 1)), to_linspace(y, size(z, 2)), z) end """ - convert_arguments(P, Matrix)::Tuple{ClosedInterval, ClosedInterval, Matrix} + convert_arguments(::ImageLike, mat::AbstractMatrix) -Takes an `AbstractMatrix`, converts the dimesions `n` and `m` into `ClosedInterval`, -and stores the `ClosedInterval` to `n` and `m`, plus the original matrix in a Tuple. - -`P` is the plot Type (it is optional). +Generates `ClosedInterval`s of size `0 .. size(mat, 1/2)` as x and y values. """ -function convert_arguments(sl::SurfaceLike, data::AbstractMatrix) +function convert_arguments(::ImageLike, data::AbstractMatrix) n, m = Float32.(size(data)) - convert_arguments(sl, 0f0 .. n, 0f0 .. m, el32convert(data)) + return (0f0 .. n, 0f0 .. m, el32convert(data)) end -function convert_arguments(ds::DiscreteSurface, data::AbstractMatrix) +function print_range_warning(side::String, value) + @warn "Encountered an `AbstractVector` with value $value on side $side in `convert_arguments` for the `ImageLike` trait. Using an `AbstractVector` to specify one dimension of an `ImageLike` is deprecated because `ImageLike` sides always need exactly two values, start and stop. Use interval notation `start .. stop` or a two-element tuple `(start, stop)` instead." +end + +function convert_arguments(::ImageLike, xs::RangeLike, ys::RangeLike, data::AbstractMatrix) + if xs isa AbstractVector + print_range_warning("x", xs) + end + if ys isa AbstractVector + print_range_warning("y", ys) + end + _interval(v::Union{Interval,AbstractVector}) = Float32(minimum(v)) .. Float32(maximum(v)) # having minimum and maximum here actually invites bugs + _interval(t::Tuple{Any, Any}) = Float32(t[1]) .. Float32(t[2]) + x = _interval(xs) + y = _interval(ys) + return (x, y, el32convert(data)) +end + +function convert_arguments(ct::GridBased, data::AbstractMatrix) n, m = Float32.(size(data)) - convert_arguments(ds, edges(1:n), edges(1:m), el32convert(data)) + convert_arguments(ct, 1f0 .. n, 1f0 .. m, el32convert(data)) end -function convert_arguments(SL::SurfaceLike, x::AbstractVector{<:Number}, y::AbstractVector{<:Number}, z::AbstractVector{<:Number}) +function convert_arguments(ct::GridBased, x::AbstractVector{<:Number}, y::AbstractVector{<:Number}, z::AbstractVector{<:Number}) if !(length(x) == length(y) == length(z)) error("x, y and z need to have the same length. Lengths are $(length.((x, y, z)))") end @@ -397,7 +419,7 @@ function convert_arguments(SL::SurfaceLike, x::AbstractVector{<:Number}, y::Abst j = searchsortedfirst(y_centers, yi) @inbounds zs[i, j] = zi end - convert_arguments(SL, x_centers, y_centers, zs) + convert_arguments(ct, x_centers, y_centers, zs) end @@ -408,14 +430,14 @@ Takes vectors `x` and `y` and the function `f`, and applies `f` on the grid that This is equivalent to `f.(x, y')`. `P` is the plot Type (it is optional). """ -function convert_arguments(sl::SurfaceLike, x::AbstractVector{T1}, y::AbstractVector{T2}, f::Function) where {T1, T2} +function convert_arguments(ct::Union{GridBased, ImageLike}, x::AbstractVector{T1}, y::AbstractVector{T2}, f::Function) where {T1, T2} if !applicable(f, x[1], y[1]) error("You need to pass a function with signature f(x::$T1, y::$T2). Found: $f") end T = typeof(f(x[1], y[1])) z = similar(x, T, (length(x), length(y))) z .= f.(x, y') - return convert_arguments(sl, x, y, z) + return convert_arguments(ct, x, y, z) end ################################################################################ @@ -449,26 +471,6 @@ function convert_arguments(::VolumeLike, x::AbstractVector, y::AbstractVector, z (x, y, z, el32convert(i)) end - -""" - convert_arguments(P, x, y, z, f)::(Vector, Vector, Vector, Matrix) - -Takes `AbstractVector` `x`, `y`, and `z` and the function `f`, evaluates `f` on the volume -spanned by `x`, `y` and `z`, and puts `x`, `y`, `z` and `f(x,y,z)` in a Tuple. - -`P` is the plot Type (it is optional). -""" -function convert_arguments(::VolumeLike, x::AbstractVector, y::AbstractVector, z::AbstractVector, f::Function) - if !applicable(f, x[1], y[1], z[1]) - error("You need to pass a function with signature f(x, y, z). Found: $f") - end - _x, _y, _z = ntuple(Val(3)) do i - A = (x, y, z)[i] - reshape(A, ntuple(j-> j != i ? 1 : length(A), Val(3))) - end - return (x, y, z, el32convert.(f.(_x, _y, _z))) -end - ################################################################################ # <:Lines # ################################################################################ @@ -543,10 +545,10 @@ end function convert_arguments(::Type{<:Mesh}, mesh::GeometryBasics.Mesh{N}) where {N} # Make sure we have normals! if !hasproperty(mesh, :normals) - n = normals(mesh) + n = normals(metafree(decompose(Point, mesh)), faces(mesh)) # Normals can be nothing, when it's impossible to calculate the normals (e.g. 2d mesh) - if n !== nothing - mesh = GeometryBasics.pointmeta(mesh, decompose(Vec3f, n)) + if !isnothing(n) + mesh = GeometryBasics.pointmeta(mesh; normals=decompose(Vec3f, n)) end end # If already correct eltypes for GL, we can pass the mesh through as is @@ -604,25 +606,69 @@ function convert_arguments( vertices::AbstractArray, indices::AbstractArray ) - m = normal_mesh(to_vertices(vertices), to_triangles(indices)) - (m,) + vs = to_vertices(vertices) + fs = to_triangles(indices) + if eltype(vs) <: Point{3} + ns = normals(vs, fs) + m = GeometryBasics.Mesh(meta(vs; normals=ns), fs) + else + # TODO, we don't need to add normals here, but maybe nice for type stability? + m = GeometryBasics.Mesh(meta(vs; normals=fill(Vec3f(0, 0, 1), length(vs))), fs) + end + return (m,) end + ################################################################################ # Function Conversions # ################################################################################ +# Allow the user to pass a function to `arrows` which determines the direction +# and magnitude of the arrows. The function must accept `Point2f` as input. +# and return Point2f or Vec2f or some array like structure as output. +function convert_arguments(::Type{<:Arrows}, x::AbstractVector, y::AbstractVector, f::Function) + points = Point2f.(x, y') + f_out = Vec2f.(f.(points)) + return (vec(points), vec(f_out)) +end + +function convert_arguments(::Type{<:Arrows}, x::AbstractVector, y::AbstractVector, z::AbstractVector, + f::Function) + points = [Point3f(x, y, z) for x in x, y in y, z in z] + f_out = Vec3f.(f.(points)) + return (vec(points), vec(f_out)) +end + +""" + convert_arguments(P, x, y, z, f)::(Vector, Vector, Vector, Matrix) + +Takes `AbstractVector` `x`, `y`, and `z` and the function `f`, evaluates `f` on the volume +spanned by `x`, `y` and `z`, and puts `x`, `y`, `z` and `f(x,y,z)` in a Tuple. + +`P` is the plot Type (it is optional). +""" +function convert_arguments(::VolumeLike, x::AbstractVector, y::AbstractVector, z::AbstractVector, f::Function) + if !applicable(f, x[1], y[1], z[1]) + error("You need to pass a function with signature f(x, y, z). Found: $f") + end + _x, _y, _z = ntuple(Val(3)) do i + A = (x, y, z)[i] + return reshape(A, ntuple(j -> j != i ? 1 : length(A), Val(3))) + end + return (x, y, z, el32convert.(f.(_x, _y, _z))) +end + function convert_arguments(P::PlotFunc, r::AbstractVector, f::Function) - ptype = plottype(P, Lines) - to_plotspec(ptype, convert_arguments(ptype, r, f.(r))) + return convert_arguments(P, r, map(f, r)) end function convert_arguments(P::PlotFunc, i::AbstractInterval, f::Function) x, y = PlotUtils.adapted_grid(f, endpoints(i)) - ptype = plottype(P, Lines) - to_plotspec(ptype, convert_arguments(ptype, x, y)) + return convert_arguments(P, x, y) end + + # The following `tryrange` code was copied from Plots.jl # https://github.com/MakieOrg/Plots.jl/blob/15dc61feb57cba1df524ce5d69f68c2c4ea5b942/src/series.jl#L399-L416 @@ -645,8 +691,9 @@ function tryrange(F, vec) error("$F is not a Function, or is not defined at any of the values $vec") end + # OffsetArrays conversions -function convert_arguments(sl::SurfaceLike, wm::OffsetArray) +function convert_arguments(sl::GridBased, wm::OffsetArray) x1, y1 = wm.offsets .+ 1 nx, ny = size(wm) x = range(x1, length = nx) @@ -727,12 +774,12 @@ Converts a representation of vertices `v` to its canonical representation as a - otherwise if `v` has 2 or 3 columns, it will treat each row as a vertex. """ function to_vertices(verts::AbstractVector{<: VecTypes{3, T}}) where T - vert3f0 = T != Float32 ? Point3f.(verts) : verts + vert3f0 = T != Float32 ? map(Point3f, verts) : verts return reinterpret(Point3f, vert3f0) end -function to_vertices(verts::AbstractVector{<: VecTypes}) - to_vertices(to_ndim.(Point3f, verts, 0.0)) +function to_vertices(verts::AbstractVector{<: VecTypes{N}}) where {N} + return map(Point{N, Float32}, verts) end function to_vertices(verts::AbstractMatrix{<: Number}) @@ -752,7 +799,7 @@ function to_vertices(verts::AbstractMatrix{T}, ::Val{1}) where T <: Number else let N = Val(N), lverts = verts broadcast(1:size(verts, 2), N) do vidx, n - to_ndim(Point3f, ntuple(i-> lverts[i, vidx], n), 0.0) + Point(ntuple(i-> Float32(lverts[i, vidx]), n)) end end end @@ -761,7 +808,7 @@ end function to_vertices(verts::AbstractMatrix{T}, ::Val{2}) where T <: Number let N = Val(size(verts, 2)), lverts = verts broadcast(1:size(verts, 1), N) do vidx, n - to_ndim(Point3f, ntuple(i-> lverts[vidx, i], n), 0.0) + Point(ntuple(i-> Float32(verts[vidx, i]), n)) end end end @@ -842,38 +889,65 @@ convert_attribute(c::Number, ::key"strokewidth") = Float32(c) convert_attribute(c, ::key"glowcolor") = to_color(c) convert_attribute(c, ::key"strokecolor") = to_color(c) -convert_attribute(x::Nothing, ::key"linestyle") = x +#### +## Line style conversions +#### + +convert_attribute(style, ::key"linestyle") = to_linestyle(style) +to_linestyle(::Nothing) = nothing +# add deprecation for old conversion +function convert_attribute(style::AbstractVector, ::key"linestyle") + @warn "Using a `Vector{<:Real}` as a linestyle attribute is deprecated. Wrap it in a `Linestyle`." + return to_linestyle(Linestyle(style)) +end + +""" + Linestyle(value::Vector{<:Real}) + +A type that can be used as value for the `linestyle` keyword argument +of plotting functions to arbitrarily customize the linestyle. -# `AbstractVector{<:AbstractFloat}` for denoting sequences of fill/nofill. e.g. -# -# [0.5, 0.8, 1.2] will result in 0.5 filled, 0.3 unfilled, 0.4 filled. 1.0 unit is one linewidth! -convert_attribute(A::AbstractVector, ::key"linestyle") = [float(x - A[1]) for x in A] +The `value` is a vector of positions where the line flips from being drawn or not +and vice versa. The values of `value` are in units of linewidth. + +For example, with `value = [0.0, 4.0, 6.0, 9.5]` +you start drawing at 0, stop at 4 linewidths, start again at 6, stop at 9.5, +then repeat with 0 and 9.5 being treated as the same position. +""" +struct Linestyle + value::Vector{Float32} +end + +to_linestyle(style::Linestyle) = Float32[x - style.value[1] for x in style.value] + +# TODO only use NTuple{2, <: Real} and not any other container +const GapType = Union{Real, Symbol, Tuple, AbstractVector} # A `Symbol` equal to `:dash`, `:dot`, `:dashdot`, `:dashdotdot` -convert_attribute(ls::Union{Symbol,AbstractString}, ::key"linestyle") = line_pattern(ls, :normal) +to_linestyle(ls::Union{Symbol, AbstractString}) = line_pattern(ls, :normal) -function convert_attribute(ls::Tuple{<:Union{Symbol,AbstractString},<:Any}, ::key"linestyle") - line_pattern(ls[1], ls[2]) +function to_linestyle(ls::Tuple{<:Union{Symbol, AbstractString}, <: GapType}) + return line_pattern(ls[1], ls[2]) end -function line_pattern(linestyle, gaps) +function line_pattern(linestyle::Symbol, gaps::GapType) pattern = line_diff_pattern(linestyle, gaps) - isnothing(pattern) ? pattern : float.([0.0; cumsum(pattern)]) + return isnothing(pattern) ? pattern : Float32[0.0; cumsum(pattern)] end "The linestyle patterns are inspired by the LaTeX package tikZ as seen here https://tex.stackexchange.com/questions/45275/tikz-get-values-for-predefined-dash-patterns." -function line_diff_pattern(ls::Symbol, gaps = :normal) +function line_diff_pattern(ls::Symbol, gaps::GapType = :normal) if ls === :solid - nothing + return nothing elseif ls === :dash - line_diff_pattern("-", gaps) + return line_diff_pattern("-", gaps) elseif ls === :dot - line_diff_pattern(".", gaps) + return line_diff_pattern(".", gaps) elseif ls === :dashdot - line_diff_pattern("-.", gaps) + return line_diff_pattern("-.", gaps) elseif ls === :dashdotdot - line_diff_pattern("-..", gaps) + return line_diff_pattern("-..", gaps) else error( """ @@ -886,7 +960,7 @@ function line_diff_pattern(ls::Symbol, gaps = :normal) end end -function line_diff_pattern(ls_str::AbstractString, gaps = :normal) +function line_diff_pattern(ls_str::AbstractString, gaps::GapType = :normal) dot = 1 dash = 3 check_line_pattern(ls_str) @@ -921,24 +995,24 @@ function check_line_pattern(ls_str) nothing end -function convert_gaps(gaps) - error_msg = "You provided the gaps modifier $gaps when specifying the linestyle. The modifier must be `∈ ([:normal, :dense, :loose])`, a real number or a collection of two real numbers." - if gaps isa Symbol - gaps in [:normal, :dense, :loose] || throw(ArgumentError(error_msg)) - dot_gaps = (normal = 2, dense = 1, loose = 4) - dash_gaps = (normal = 3, dense = 2, loose = 6) - - dot_gap = getproperty(dot_gaps, gaps) - dash_gap = getproperty(dash_gaps, gaps) - elseif gaps isa Real - dot_gap = gaps - dash_gap = gaps - elseif length(gaps) == 2 && eltype(gaps) <: Real - dot_gap, dash_gap = gaps - else - throw(ArgumentError(error_msg)) - end - (dot_gap = dot_gap, dash_gap = dash_gap) +function convert_gaps(gaps::GapType) + error_msg = "You provided the gaps modifier $gaps when specifying the linestyle. The modifier must be one of the symbols `:normal`, `:dense` or `:loose`, a real number or a tuple of two real numbers." + if gaps isa Symbol + gaps in [:normal, :dense, :loose] || throw(ArgumentError(error_msg)) + dot_gaps = (normal = 2, dense = 1, loose = 4) + dash_gaps = (normal = 3, dense = 2, loose = 6) + + dot_gap = getproperty(dot_gaps, gaps) + dash_gap = getproperty(dash_gaps, gaps) + elseif gaps isa Real + dot_gap = gaps + dash_gap = gaps + elseif length(gaps) == 2 && eltype(gaps) <: Real + dot_gap, dash_gap = gaps + else + throw(ArgumentError(error_msg)) + end + return (dot_gap = dot_gap, dash_gap = dash_gap) end convert_attribute(c::Tuple{<: Number, <: Number}, ::key"position") = Point2f(c[1], c[2]) @@ -946,10 +1020,81 @@ convert_attribute(c::Tuple{<: Number, <: Number, <: Number}, ::key"position") = convert_attribute(c::VecTypes{N}, ::key"position") where N = Point{N, Float32}(c) """ - Text align, e.g.: + to_align(align[, error_prefix]) + +Converts the given align to a `Vec2f`. Can convert `VecTypes{2}` and two +component `Tuple`s with `Real` and `Symbol` elements. + +To specify a custom error message you can add an `error_prefix` or use +`halign2num(value, error_msg)` and `valign2num(value, error_msg)` respectively. +""" +to_align(x::Tuple) = Vec2f(halign2num(x[1]), valign2num(x[2])) +to_align(x::VecTypes{2, <:Real}) = Vec2f(x) + +function to_align(v, error_prefix::String) + try + return to_align(v) + catch + error(error_prefix) + end +end + +""" + halign2num(align[, error_msg]) + +Attempts to convert a horizontal align to a Float32 and errors with `error_msg` +if it fails to do so. +""" +halign2num(v::Real, error_msg = "") = Float32(v) +function halign2num(v::Symbol, error_msg = "Invalid halign $v. Valid values are <:Real, :left, :center and :right.") + if v === :left + return 0.0f0 + elseif v === :center + return 0.5f0 + elseif v === :right + return 1.0f0 + else + error(error_msg) + end +end +function halign2num(v, error_msg = "Invalid halign $v. Valid values are <:Real, :left, :center and :right.") + error(error_msg) +end + +""" + valign2num(align[, error_msg]) + +Attempts to convert a vertical align to a Float32 and errors with `error_msg` +if it fails to do so. """ -to_align(x::Tuple{Symbol, Symbol}) = Vec2f(alignment2num.(x)) -to_align(x::Vec2f) = x +valign2num(v::Real, error_msg = "") = Float32(v) +function valign2num(v::Symbol, error_msg = "Invalid valign $v. Valid values are <:Real, :bottom, :top, and :center.") + if v === :top + return 1f0 + elseif v === :bottom + return 0f0 + elseif v === :center + return 0.5f0 + else + error(error_msg) + end +end +function valign2num(v, error_msg = "Invalid valign $v. Valid values are <:Real, :bottom, :top, and :center.") + error(error_msg) +end + +""" + angle2align(angle::Real) + +Converts a given angle to an alignment by projecting the resulting direction on +a unit square and scaling the result to a 0..1 range appropriate for alignments. +""" +function angle2align(angle::Real) + s, c = sincos(angle) + scale = 1 / max(abs(s), abs(c)) + return Vec2f(0.5scale * c + 0.5, 0.5scale * s + 0.5) +end + const FONT_CACHE = Dict{String, NativeFont}() const FONT_CACHE_LOCK = Base.ReentrantLock() @@ -1208,42 +1353,263 @@ function convert_attribute(value::Union{Symbol, String}, k::key"algorithm") end, k) end -const DEFAULT_MARKER_MAP = Dict{Symbol, BezierPath}() +#= +The below is the output from: +```julia +# The bezier markers should not look out of place when used together with text +# where both markers and text are given the same size, i.e. the marker and fontsizes +# should correspond approximately in a visual sense. + +# All the basic bezier shapes are approximately built in a 1 by 1 square centered +# around the origin, with slight deviations to match them better to each other. + +# An 'x' of DejaVu sans is only about 55pt high at 100pt font size, so if the marker +# shapes are just used as is, they look much too large in comparison. +# To me, a factor of 0.75 looks ok compared to both uppercase and lowercase letters of Dejavu. +size_factor = 0.75 +DEFAULT_MARKER_MAP[:rect] = scale(BezierSquare, size_factor) +DEFAULT_MARKER_MAP[:diamond] = scale(rotate(BezierSquare, pi/4), size_factor) +DEFAULT_MARKER_MAP[:hexagon] = scale(bezier_ngon(6, 0.5, pi/2), size_factor) +DEFAULT_MARKER_MAP[:cross] = scale(BezierCross, size_factor) +DEFAULT_MARKER_MAP[:xcross] = scale(BezierX, size_factor) +DEFAULT_MARKER_MAP[:utriangle] = scale(BezierUTriangle, size_factor) +DEFAULT_MARKER_MAP[:dtriangle] = scale(BezierDTriangle, size_factor) +DEFAULT_MARKER_MAP[:ltriangle] = scale(BezierLTriangle, size_factor) +DEFAULT_MARKER_MAP[:rtriangle] = scale(BezierRTriangle, size_factor) +DEFAULT_MARKER_MAP[:pentagon] = scale(bezier_ngon(5, 0.5, pi/2), size_factor) +DEFAULT_MARKER_MAP[:octagon] = scale(bezier_ngon(8, 0.5, pi/2), size_factor) +DEFAULT_MARKER_MAP[:star4] = scale(bezier_star(4, 0.25, 0.6, pi/2), size_factor) +DEFAULT_MARKER_MAP[:star5] = scale(bezier_star(5, 0.28, 0.6, pi/2), size_factor) +DEFAULT_MARKER_MAP[:star6] = scale(bezier_star(6, 0.30, 0.6, pi/2), size_factor) +DEFAULT_MARKER_MAP[:star8] = scale(bezier_star(8, 0.33, 0.6, pi/2), size_factor) +DEFAULT_MARKER_MAP[:vline] = scale(scale(BezierSquare, (0.2, 1.0)), size_factor) +DEFAULT_MARKER_MAP[:hline] = scale(scale(BezierSquare, (1.0, 0.2)), size_factor) +DEFAULT_MARKER_MAP[:+] = scale(BezierCross, size_factor) +DEFAULT_MARKER_MAP[:x] = scale(BezierX, size_factor) +DEFAULT_MARKER_MAP[:circle] = scale(BezierCircle, size_factor) +``` +We have to write this out to make sure we rotate/scale don't generate slightly different values between Julia versions. +This would create different hashes, making the caching in the texture atlas fail! +See: https://github.com/MakieOrg/Makie.jl/pull/3394 +=# + +const DEFAULT_MARKER_MAP = Dict(:+ => BezierPath([Makie.MoveTo([0.1245, 0.375]), + Makie.LineTo([0.1245, 0.1245]), + Makie.LineTo([0.375, 0.1245]), + Makie.LineTo([0.375, -0.12449999999999999]), + Makie.LineTo([0.1245, -0.1245]), + Makie.LineTo([0.12450000000000003, -0.375]), + Makie.LineTo([-0.12449999999999997, -0.375]), + Makie.LineTo([-0.12449999999999999, -0.12450000000000003]), + Makie.LineTo([-0.375, -0.12450000000000006]), + Makie.LineTo([-0.375, 0.12449999999999994]), + Makie.LineTo([-0.12450000000000003, 0.12449999999999999]), + Makie.LineTo([-0.12450000000000007, 0.37499999999999994]), + Makie.ClosePath()]), + :diamond => BezierPath([Makie.MoveTo([0.4464931614186469, + -5.564531862779532e-17]), + Makie.LineTo([2.10398220755128e-17, + 0.4464931614186469]), + Makie.LineTo([-0.4464931614186469, + 5.564531862779532e-17]), + Makie.LineTo([-2.10398220755128e-17, + -0.4464931614186469]), + Makie.ClosePath()]), + :star4 => BezierPath([Makie.MoveTo([2.7554554183166277e-17, + 0.44999999999999996]), + Makie.LineTo([-0.13258251920342445, + 0.13258251920342445]), + Makie.LineTo([-0.44999999999999996, + 5.5109108366332553e-17]), + Makie.LineTo([-0.13258251920342445, + -0.13258251920342445]), + Makie.LineTo([-8.266365659379842e-17, + -0.44999999999999996]), + Makie.LineTo([0.13258251920342445, + -0.13258251920342445]), + Makie.LineTo([0.44999999999999996, + -1.1021821673266511e-16]), + Makie.LineTo([0.13258251920342445, 0.13258251920342445]), + Makie.ClosePath()]), + :star8 => BezierPath([Makie.MoveTo([2.7554554183166277e-17, + 0.44999999999999996]), + Makie.LineTo([-0.09471414797008038, 0.2286601772904396]), + Makie.LineTo([-0.31819804608821867, + 0.31819804608821867]), + Makie.LineTo([-0.2286601772904396, 0.09471414797008038]), + Makie.LineTo([-0.44999999999999996, + 5.5109108366332553e-17]), + Makie.LineTo([-0.2286601772904396, + -0.09471414797008038]), + Makie.LineTo([-0.31819804608821867, + -0.31819804608821867]), + Makie.LineTo([-0.09471414797008038, + -0.2286601772904396]), + Makie.LineTo([-8.266365659379842e-17, + -0.44999999999999996]), + Makie.LineTo([0.09471414797008038, -0.2286601772904396]), + Makie.LineTo([0.31819804608821867, + -0.31819804608821867]), + Makie.LineTo([0.2286601772904396, -0.09471414797008038]), + Makie.LineTo([0.44999999999999996, + -1.1021821673266511e-16]), + Makie.LineTo([0.2286601772904396, 0.09471414797008038]), + Makie.LineTo([0.31819804608821867, 0.31819804608821867]), + Makie.LineTo([0.09471414797008038, 0.2286601772904396]), + Makie.ClosePath()]), + :star6 => BezierPath([Makie.MoveTo([2.7554554183166277e-17, + 0.44999999999999996]), + Makie.LineTo([-0.11249999999999999, 0.1948557123541832]), + Makie.LineTo([-0.3897114247083664, 0.22499999999999998]), + Makie.LineTo([-0.22499999999999998, + 2.7554554183166277e-17]), + Makie.LineTo([-0.3897114247083664, + -0.22499999999999998]), + Makie.LineTo([-0.11249999999999999, + -0.1948557123541832]), + Makie.LineTo([-8.266365659379842e-17, + -0.44999999999999996]), + Makie.LineTo([0.11249999999999999, -0.1948557123541832]), + Makie.LineTo([0.3897114247083664, -0.22499999999999998]), + Makie.LineTo([0.22499999999999998, + -5.5109108366332553e-17]), + Makie.LineTo([0.3897114247083664, 0.22499999999999998]), + Makie.LineTo([0.11249999999999999, 0.1948557123541832]), + Makie.ClosePath()]), + :rtriangle => BezierPath([Makie.MoveTo([0.485, -8.909305463796994e-17]), + Makie.LineTo([-0.24249999999999994, 0.36375]), + Makie.LineTo([-0.2425000000000001, + -0.36374999999999996]), + Makie.ClosePath()]), + :x => BezierPath([Makie.MoveTo([-0.1771302486872301, 0.35319983720268056]), + Makie.LineTo([1.39759596452057e-17, 0.17606958851545035]), + Makie.LineTo([0.17713024868723018, 0.3531998372026805]), + Makie.LineTo([0.3531998372026805, 0.17713024868723012]), + Makie.LineTo([0.17606958851545035, -1.025465786723834e-17]), + Makie.LineTo([0.3531998372026805, -0.17713024868723015]), + Makie.LineTo([0.17713024868723015, -0.3531998372026805]), + Makie.LineTo([1.1151998010815531e-17, -0.17606958851545035]), + Makie.LineTo([-0.17713024868723015, -0.3531998372026805]), + Makie.LineTo([-0.35319983720268044, -0.17713024868723018]), + Makie.LineTo([-0.17606958851545035, + -1.4873299788782892e-17]), + Makie.LineTo([-0.3531998372026805, 0.1771302486872301]), + Makie.ClosePath()]), + :circle => BezierPath([Makie.MoveTo([0.3525, 0.0]), + EllipticalArc([0.0, 0.0], 0.3525, 0.3525, 0.0, 0.0, + 6.283185307179586), Makie.ClosePath()]), + :pentagon => BezierPath([Makie.MoveTo([2.2962128485971897e-17, 0.375]), + Makie.LineTo([-0.35664620250463486, + 0.11588137596845627]), + Makie.LineTo([-0.22041946649551392, + -0.30338137596845627]), + Makie.LineTo([0.22041946649551392, + -0.30338137596845627]), + Makie.LineTo([0.35664620250463486, + 0.11588137596845627]), + Makie.ClosePath()]), + :vline => BezierPath([Makie.MoveTo([0.063143668438509, -0.315718342192545]), + Makie.LineTo([0.063143668438509, 0.315718342192545]), + Makie.LineTo([-0.063143668438509, 0.315718342192545]), + Makie.LineTo([-0.063143668438509, -0.315718342192545]), + Makie.ClosePath()]), + :cross => BezierPath([Makie.MoveTo([0.1245, 0.375]), + Makie.LineTo([0.1245, 0.1245]), + Makie.LineTo([0.375, 0.1245]), + Makie.LineTo([0.375, -0.12449999999999999]), + Makie.LineTo([0.1245, -0.1245]), + Makie.LineTo([0.12450000000000003, -0.375]), + Makie.LineTo([-0.12449999999999997, -0.375]), + Makie.LineTo([-0.12449999999999999, + -0.12450000000000003]), + Makie.LineTo([-0.375, -0.12450000000000006]), + Makie.LineTo([-0.375, 0.12449999999999994]), + Makie.LineTo([-0.12450000000000003, + 0.12449999999999999]), + Makie.LineTo([-0.12450000000000007, + 0.37499999999999994]), + Makie.ClosePath()]), + :xcross => BezierPath([Makie.MoveTo([-0.1771302486872301, + 0.35319983720268056]), + Makie.LineTo([1.39759596452057e-17, + 0.17606958851545035]), + Makie.LineTo([0.17713024868723018, 0.3531998372026805]), + Makie.LineTo([0.3531998372026805, 0.17713024868723012]), + Makie.LineTo([0.17606958851545035, + -1.025465786723834e-17]), + Makie.LineTo([0.3531998372026805, + -0.17713024868723015]), + Makie.LineTo([0.17713024868723015, + -0.3531998372026805]), + Makie.LineTo([1.1151998010815531e-17, + -0.17606958851545035]), + Makie.LineTo([-0.17713024868723015, + -0.3531998372026805]), + Makie.LineTo([-0.35319983720268044, + -0.17713024868723018]), + Makie.LineTo([-0.17606958851545035, + -1.4873299788782892e-17]), + Makie.LineTo([-0.3531998372026805, 0.1771302486872301]), + Makie.ClosePath()]), + :rect => BezierPath([Makie.MoveTo([0.315718342192545, -0.315718342192545]), + Makie.LineTo([0.315718342192545, 0.315718342192545]), + Makie.LineTo([-0.315718342192545, 0.315718342192545]), + Makie.LineTo([-0.315718342192545, -0.315718342192545]), + Makie.ClosePath()]), + :ltriangle => BezierPath([Makie.MoveTo([-0.485, 2.969768487932331e-17]), + Makie.LineTo([0.2425, -0.36375]), + Makie.LineTo([0.24250000000000005, 0.36375]), + Makie.ClosePath()]), + :dtriangle => BezierPath([Makie.MoveTo([-0.0, -0.485]), + Makie.LineTo([0.36375, 0.24250000000000002]), + Makie.LineTo([-0.36375, 0.24250000000000002]), + Makie.ClosePath()]), + :utriangle => BezierPath([Makie.MoveTo([0.0, 0.485]), + Makie.LineTo([-0.36375, -0.24250000000000002]), + Makie.LineTo([0.36375, -0.24250000000000002]), + Makie.ClosePath()]), + :star5 => BezierPath([Makie.MoveTo([2.7554554183166277e-17, + 0.44999999999999996]), + Makie.LineTo([-0.12343490123748782, + 0.16989357054233553]), + Makie.LineTo([-0.4279754430055618, 0.13905765116214752]), + Makie.LineTo([-0.19972187340259556, + -0.06489357054233552]), + Makie.LineTo([-0.2645033597946167, -0.3640576511621475]), + Makie.LineTo([-3.8576373077105933e-17, + -0.21000000000000002]), + Makie.LineTo([0.2645033597946167, -0.3640576511621475]), + Makie.LineTo([0.19972187340259556, + -0.06489357054233552]), + Makie.LineTo([0.4279754430055618, 0.13905765116214752]), + Makie.LineTo([0.12343490123748782, 0.16989357054233553]), + Makie.ClosePath()]), + :octagon => BezierPath([Makie.MoveTo([2.2962128485971897e-17, 0.375]), + Makie.LineTo([-0.2651650384068489, + 0.2651650384068489]), + Makie.LineTo([-0.375, 4.5924256971943795e-17]), + Makie.LineTo([-0.2651650384068489, + -0.2651650384068489]), + Makie.LineTo([-6.888638049483202e-17, -0.375]), + Makie.LineTo([0.2651650384068489, + -0.2651650384068489]), + Makie.LineTo([0.375, -9.184851394388759e-17]), + Makie.LineTo([0.2651650384068489, 0.2651650384068489]), + Makie.ClosePath()]), + :hline => BezierPath([Makie.MoveTo([0.315718342192545, -0.063143668438509]), + Makie.LineTo([0.315718342192545, 0.063143668438509]), + Makie.LineTo([-0.315718342192545, 0.063143668438509]), + Makie.LineTo([-0.315718342192545, -0.063143668438509]), + Makie.ClosePath()]), + :hexagon => BezierPath([Makie.MoveTo([2.2962128485971897e-17, 0.375]), + Makie.LineTo([-0.32475952059030533, 0.1875]), + Makie.LineTo([-0.32475952059030533, -0.1875]), + Makie.LineTo([-6.888638049483202e-17, -0.375]), + Makie.LineTo([0.32475952059030533, -0.1875]), + Makie.LineTo([0.32475952059030533, 0.1875]), + Makie.ClosePath()])) function default_marker_map() - # The bezier markers should not look out of place when used together with text - # where both markers and text are given the same size, i.e. the marker and fontsizes - # should correspond approximately in a visual sense. - - # All the basic bezier shapes are approximately built in a 1 by 1 square centered - # around the origin, with slight deviations to match them better to each other. - - # An 'x' of DejaVu sans is only about 55pt high at 100pt font size, so if the marker - # shapes are just used as is, they look much too large in comparison. - # To me, a factor of 0.75 looks ok compared to both uppercase and lowercase letters of Dejavu. - if isempty(DEFAULT_MARKER_MAP) - size_factor = 0.75 - DEFAULT_MARKER_MAP[:rect] = scale(BezierSquare, size_factor) - DEFAULT_MARKER_MAP[:diamond] = scale(rotate(BezierSquare, pi/4), size_factor) - DEFAULT_MARKER_MAP[:hexagon] = scale(bezier_ngon(6, 0.5, pi/2), size_factor) - DEFAULT_MARKER_MAP[:cross] = scale(BezierCross, size_factor) - DEFAULT_MARKER_MAP[:xcross] = scale(BezierX, size_factor) - DEFAULT_MARKER_MAP[:utriangle] = scale(BezierUTriangle, size_factor) - DEFAULT_MARKER_MAP[:dtriangle] = scale(BezierDTriangle, size_factor) - DEFAULT_MARKER_MAP[:ltriangle] = scale(BezierLTriangle, size_factor) - DEFAULT_MARKER_MAP[:rtriangle] = scale(BezierRTriangle, size_factor) - DEFAULT_MARKER_MAP[:pentagon] = scale(bezier_ngon(5, 0.5, pi/2), size_factor) - DEFAULT_MARKER_MAP[:octagon] = scale(bezier_ngon(8, 0.5, pi/2), size_factor) - DEFAULT_MARKER_MAP[:star4] = scale(bezier_star(4, 0.25, 0.6, pi/2), size_factor) - DEFAULT_MARKER_MAP[:star5] = scale(bezier_star(5, 0.28, 0.6, pi/2), size_factor) - DEFAULT_MARKER_MAP[:star6] = scale(bezier_star(6, 0.30, 0.6, pi/2), size_factor) - DEFAULT_MARKER_MAP[:star8] = scale(bezier_star(8, 0.33, 0.6, pi/2), size_factor) - DEFAULT_MARKER_MAP[:vline] = scale(scale(BezierSquare, (0.2, 1.0)), size_factor) - DEFAULT_MARKER_MAP[:hline] = scale(scale(BezierSquare, (1.0, 0.2)), size_factor) - DEFAULT_MARKER_MAP[:+] = scale(BezierCross, size_factor) - DEFAULT_MARKER_MAP[:x] = scale(BezierX, size_factor) - DEFAULT_MARKER_MAP[:circle] = scale(BezierCircle, size_factor) - end return DEFAULT_MARKER_MAP end @@ -1287,6 +1653,7 @@ to_spritemarker(x::Rect) = x to_spritemarker(b::BezierPath) = b to_spritemarker(b::Polygon) = BezierPath(b) to_spritemarker(b) = error("Not a valid scatter marker: $(typeof(b))") +to_spritemarker(x::Shape) = x function to_spritemarker(str::String) error("Using strings for multiple char markers is deprecated. Use `collect(string)` or `['x', 'o', ...]` instead. Found: $(str)") @@ -1342,3 +1709,35 @@ end convert_attribute(value, ::key"diffuse") = Vec3f(value) convert_attribute(value, ::key"specular") = Vec3f(value) + +convert_attribute(value, ::key"backlight") = Float32(value) + + +# SAMPLER overloads + +convert_attribute(s::ShaderAbstractions.Sampler{RGBAf}, k::key"color") = s +function convert_attribute(s::ShaderAbstractions.Sampler{T,N}, k::key"color") where {T,N} + return ShaderAbstractions.Sampler(el32convert(s.data); minfilter=s.minfilter, magfilter=s.magfilter, + x_repeat=s.repeat[1], y_repeat=s.repeat[min(2, N)], + z_repeat=s.repeat[min(3, N)], + anisotropic=s.anisotropic, color_swizzel=s.color_swizzel) +end + +function el32convert(x::ShaderAbstractions.Sampler{T,N}) where {T,N} + T32 = float32type(T) + T32 === T && return x + data = el32convert(x.data) + return ShaderAbstractions.Sampler{T32,N,typeof(data)}(data, x.minfilter, x.magfilter, + x.repeat, + x.anisotropic, + x.color_swizzel, + ShaderAbstractions.ArrayUpdater(data, x.updates.update)) +end + +to_color(sampler::ShaderAbstractions.Sampler) = el32convert(sampler) + +assemble_colors(::ShaderAbstractions.Sampler, color, plot) = Observable(el32convert(color[])) + +# BUFFER OVERLOAD + +GeometryBasics.collect_with_eltype(::Type{T}, vec::ShaderAbstractions.Buffer{T}) where {T} = vec diff --git a/src/deprecated.jl b/src/deprecated.jl index 5bc7fe2b141..de9cbdc9fed 100644 --- a/src/deprecated.jl +++ b/src/deprecated.jl @@ -1,9 +1,16 @@ -function register_backend!(backend) - @warn("`register_backend!` is an internal deprecated function, which shouldn't be used outside Makie. - if you must really use this function, it's now `set_active_backend!(::Module)") -end +########################################### +# v0.20 deprecations: +## +Base.@deprecate_binding DiscreteSurface CellGrid true +Base.@deprecate_binding ContinuousSurface VertexGrid true -function backend_display(args...) - @warn("`backend_display` is an internal deprecated function, which shouldn't be used outside Makie. - if you must really use this function, it's now just `display(::Backend.Screen, figlike)`") +function Base.getproperty(scene::Scene, field::Symbol) + if field === :px_area + @warn "`.px_area` got renamed to `.viewport`, and means the area the scene maps to in device independent units, not pixels. Note, `size(scene) == widths(scene.viewport[])`" + return scene.viewport + end + return getfield(scene, field) end + +@deprecate pixelarea viewport true +Base.@deprecate_binding Combined Plot true diff --git a/src/display.jl b/src/display.jl index b06739085c0..1ec5c5d0120 100644 --- a/src/display.jl +++ b/src/display.jl @@ -66,14 +66,13 @@ function set_screen_config!(backend::Module, new_values) return backend_defaults end -function merge_screen_config(::Type{Config}, screen_config_kw) where Config +function merge_screen_config(::Type{Config}, config::Dict) where Config backend = parentmodule(Config) key = nameof(backend) backend_defaults = CURRENT_DEFAULT_THEME[key] - kw_nt = values(screen_config_kw) arguments = map(fieldnames(Config)) do name - if haskey(kw_nt, name) - return getfield(kw_nt, name) + if haskey(config, name) + return config[name] else return to_value(backend_defaults[name]) end @@ -110,9 +109,9 @@ end can_show_inline(::Missing) = false # no backend function can_show_inline(Backend) - for mime in [MIME"juliavscode/html"(), MIME"text/html"(), MIME"image/png"(), MIME"image/svg+xml"()] - if backend_showable(Backend.Screen, mime) - return has_mime_display(mime) + for mime in (MIME"juliavscode/html"(), MIME"text/html"(), MIME"image/png"(), MIME"image/svg+xml"()) + if backend_showable(Backend.Screen, mime) && has_mime_display(mime) + return true end end return false @@ -130,6 +129,7 @@ see `?Backend.Screen` or `Base.doc(Backend.Screen)` for applicable options. """ function Base.display(figlike::FigureLike; backend=current_backend(), inline=ALWAYS_INLINE_PLOTS[], update = true, screen_config...) + config = Dict{Symbol, Any}(screen_config) if ismissing(backend) error(""" No backend available! @@ -141,10 +141,15 @@ function Base.display(figlike::FigureLike; backend=current_backend(), end # We show inline if explicitely requested or if automatic and we can actually show something inline! + scene = get_scene(figlike) if (inline === true || inline === automatic) && can_show_inline(backend) + # We can't forward the screenconfig to show, but show uses the current screen if there is any + # We use that, to create a screen before show and rely on show picking up that screen + screen = getscreen(backend, scene, config) + push_screen!(scene, screen) Core.invoke(display, Tuple{Any}, figlike) # In WGLMakie, we need to wait for the display being done - screen = getscreen(get_scene(figlike)) + screen = getscreen(scene) wait_for_display(screen) return screen else @@ -156,9 +161,8 @@ function Base.display(figlike::FigureLike; backend=current_backend(), If this wasn't set on purpose, call `Makie.inline!()` to restore the default. """ end - scene = get_scene(figlike) update && update_state_before_display!(figlike) - screen = getscreen(backend, scene; screen_config...) + screen = getscreen(backend, scene, config) display(screen, scene) return screen end @@ -204,9 +208,16 @@ end Base.showable(mime::MIME, fig::FigureLike) = _backend_showable(mime) -# need to define this to resolve ambiguoity issue +# need to define this to resolve ambiguity issue Base.showable(mime::MIME"application/json", fig::FigureLike) = _backend_showable(mime) +const WEB_MIMES = ( + MIME"text/html", + MIME"application/vnd.webio.application+html", + MIME"application/prs.juno.plotpane+html", + MIME"juliavscode/html") + + backend_showable(@nospecialize(screen), @nospecialize(mime)) = false # fallback show when no backend is selected @@ -244,7 +255,7 @@ function Base.show(io::IO, m::MIME, figlike::FigureLike) backend = current_backend() # get current screen the scene is already displayed on, or create a new screen update_state_before_display!(figlike) - screen = getscreen(backend, scene, io, m; visible=false) + screen = getscreen(backend, scene, Dict(:visible=>false), io, m) backend_show(screen, io, m, scene) return screen end @@ -263,7 +274,7 @@ filetype(::FileIO.File{F}) where F = F # Allow format to be overridden with first argument """ - FileIO.save(filename, scene; resolution = size(scene), pt_per_unit = 0.75, px_per_unit = 1.0) + FileIO.save(filename, scene; size = size(scene), pt_per_unit = 0.75, px_per_unit = 1.0) Save a `Scene` with the specified filename and format. @@ -277,7 +288,7 @@ Save a `Scene` with the specified filename and format. ## All Backends -- `resolution`: `(width::Int, height::Int)` of the scene in dimensionless units (equivalent to `px` for GLMakie and WGLMakie). +- `size`: `(width::Int, height::Int)` of the scene in dimensionless units. - `update`: Whether the figure should be updated before saving. This resets the limits of all Axes in the figure. Defaults to `true`. - `backend`: Specify the `Makie` backend that should be used for saving. Defaults to the current backend. - Further keywords will be forwarded to the screen. @@ -296,14 +307,19 @@ end function FileIO.save( file::FileIO.Formatted, fig::FigureLike; - resolution = size(get_scene(fig)), + size = Base.size(get_scene(fig)), + resolution = nothing, backend = current_backend(), update = true, screen_config... ) scene = get_scene(fig) - if resolution != size(scene) - resize!(scene, resolution) + if resolution !== nothing + @warn "The keyword argument `resolution` for `save()` has been deprecated. Use `size` instead, which better reflects that this is a unitless size and not a pixel resolution." + size = resolution + end + if size != Base.size(scene) + resize!(scene, size) end filename = FileIO.filename(file) # Delete previous file if it exists and query only the file string for type. @@ -315,12 +331,16 @@ function FileIO.save( # query the filetype only from the file extension F = filetype(file) mime = format2mime(F) + try return open(filename, "w") do io # If the scene already got displayed, we get the current screen its displayed on # Else, we create a new scene and update the state of the fig update && update_state_before_display!(fig) - screen = getscreen(backend, scene, io, mime; visible=false, screen_config...) + visible = isvisible(getscreen(scene)) # if already has a screen, don't hide it! + config = Dict{Symbol, Any}(screen_config) + get!(config, :visible, visible) + screen = getscreen(backend, scene, config, io, mime) backend_show(screen, io, mime, scene) end catch e @@ -387,9 +407,9 @@ end getscreen(scene::SceneLike, backend=current_backend()) = getscreen(get_scene(scene), backend) -function getscreen(backend::Union{Missing, Module}, scene::Scene, args...; screen_config...) +function getscreen(backend::Union{Missing, Module}, scene::Scene, _config::Dict, args...) screen = getscreen(scene, backend) - config = Makie.merge_screen_config(backend.ScreenConfig, screen_config) + config = merge_screen_config(backend.ScreenConfig, _config) if !isnothing(screen) && parentmodule(typeof(screen)) == backend new_screen = apply_screen_config!(screen, config, scene, args...) if new_screen !== screen @@ -408,9 +428,20 @@ function getscreen(backend::Union{Missing, Module}, scene::Scene, args...; scree end end +function get_sub_picture(image, format::ImageStorageFormat, rect) + xmin, ymin = minimum(rect) .- Vec(1, 0) + xmax, ymax = maximum(rect) + start = size(image, 1) - ymax + stop = size(image, 1) - ymin + return image[start:stop, xmin:xmax] +end + +# Needs to be overloaded by backends, with fallback true +isvisible(screen::MakieScreen) = true +isvisible(::Nothing) = false + """ - colorbuffer(scene, format::ImageStorageFormat = JuliaNative; backend=current_backend(), screen_config...) - colorbuffer(screen, format::ImageStorageFormat = JuliaNative) + colorbuffer(scene, format::ImageStorageFormat = JuliaNative; update=true, backend=current_backend(), screen_config...) Returns the content of the given scene or screen rasterised to a Matrix of Colors. The return type is backend-dependent, but will be some form of RGB @@ -421,23 +452,43 @@ or RGBA. - `format = GLNative` : Returns a more efficient format buffer for GLMakie which can be directly used in FFMPEG without conversion - `screen_config`: Backend dependend, look up via `?Backend.Screen`/`Base.doc(Backend.Screen)` +- `update=true`: resets/updates limits. Set to false, if you want to preserver camera movements. """ function colorbuffer(fig::FigureLike, format::ImageStorageFormat = JuliaNative; update=true, backend = current_backend(), screen_config...) scene = get_scene(fig) update && update_state_before_display!(fig) - screen = getscreen(backend, scene, format; start_renderloop=false, visible=false, screen_config...) - return colorbuffer(screen, format) + # if already has a screen, use their visibility value, if no screen, returns false + visible = isvisible(getscreen(scene)) + config = Dict{Symbol,Any}(screen_config) + get!(config, :visible, visible) + get!(config, :start_renderloop, false) + screen = getscreen(backend, scene, config) + img = colorbuffer(screen, format) + if !isroot(scene) + return get_sub_picture(img, format, viewport(scene)[]) + else + return img + end end # Fallback for any backend that will just use colorbuffer to write out an image -function backend_show(screen::MakieScreen, io::IO, m::MIME"image/png", scene::Scene) +function backend_show(screen::MakieScreen, io::IO, ::MIME"image/png", scene::Scene) img = colorbuffer(screen) FileIO.save(FileIO.Stream{FileIO.format"PNG"}(Makie.raw_io(io)), img) return end -function backend_show(screen::MakieScreen, io::IO, m::MIME"image/jpeg", scene::Scene) - img = colorbuffer(scene) +function backend_show(screen::MakieScreen, io::IO, ::MIME"image/jpeg", scene::Scene) + img = colorbuffer(screen) FileIO.save(FileIO.Stream{FileIO.format"JPEG"}(Makie.raw_io(io)), img) return end + +function backend_show(screen::MakieScreen, io::IO, ::Union{WEB_MIMES...}, scene::Scene) + w, h = size(scene) + png_io = IOBuffer() + backend_show(screen, png_io, MIME"image/png"(), scene) + b64 = Base64.base64encode(String(take!(png_io))) + print(io, "") + return +end diff --git a/src/documentation/documentation.jl b/src/documentation/documentation.jl index ce83835cd4c..31001416569 100644 --- a/src/documentation/documentation.jl +++ b/src/documentation/documentation.jl @@ -186,7 +186,7 @@ to_func(func::Function) = func Maps the input of a function name to its cooresponding Type. """ -to_type(func::Function) = Combined{func} +to_type(func::Function) = Plot{func} to_type(Typ::Type{T}) where T <: AbstractPlot = Typ diff --git a/src/ffmpeg-util.jl b/src/ffmpeg-util.jl index 32ea56ae096..137524bd342 100644 --- a/src/ffmpeg-util.jl +++ b/src/ffmpeg-util.jl @@ -17,15 +17,15 @@ higher numbers giving lower quality and smaller file sizes (higher compression). The minimum value is `0` (lossless encoding). - For `mp4`, `51` is the maximum. Note that `compression = 0` only works with `mp4` if - `profile = high444`. + `profile = "high444"`. - For `webm`, `63` is the maximum. - `compression` has no effect on `mkv` and `gif` outputs. - `profile = "high422"`: A ffmpeg compatible profile. Currently only applies to `mp4`. If you have issues playing a video, try `profile = "high"` or `profile = "main"`. - `pixel_format = "yuv420p"`: A ffmpeg compatible pixel format (`-pix_fmt`). Currently only -applies to `mp4`. Defaults to `yuv444p` for `profile = high444`. +applies to `mp4`. Defaults to `yuv444p` for `profile = "high444"`. - `loop = 0`: Number of times the video is repeated, for a `gif`. Defaults to `0`, which -means infinite looping. A value of `-1` turns off looping, and a value of `n > 0` and above +means infinite looping. A value of `-1` turns off looping, and a value of `n > 0` and above means `n` repetitions (i.e. the video is played `n+1` times). !!! warning @@ -48,12 +48,12 @@ struct VideoStreamOptions function VideoStreamOptions( format::AbstractString, framerate::Real, compression, profile, pixel_format, loop, loglevel::String, input::String, rawvideo::Bool=true) - + if !isa(framerate, Integer) @warn "The given framefrate is not a subtype of `Integer`, and will be rounded to the nearest integer. To supress this warning, provide an integer as the framerate." framerate = round(Int, framerate) end - + if format == "mp4" (profile === nothing) && (profile = "high422") (pixel_format === nothing) && (pixel_format = (profile == "high444" ? "yuv444p" : "yuv420p")) @@ -62,7 +62,7 @@ struct VideoStreamOptions if format in ("mp4", "webm") (compression === nothing) && (compression = 20) end - + if format == "gif" (loop === nothing) && (loop = 0) end @@ -131,7 +131,6 @@ function to_ffmpeg_cmd(vso::VideoStreamOptions, xdim::Integer=0, ydim::Integer=0 cpu_cores = length(Sys.cpu_info()) ffmpeg_prefix = ` - $(FFMPEG.ffmpeg) -y -loglevel $(vso.loglevel) -threads $(cpu_cores)` @@ -215,21 +214,24 @@ $(Base.doc(VideoStreamOptions)) """ function VideoStream(fig::FigureLike; format="mp4", framerate=24, compression=nothing, profile=nothing, pixel_format=nothing, loop=nothing, - loglevel="quiet", visible=false, connect=false, backend=current_backend(), + loglevel="quiet", visible=false, update=true, backend=current_backend(), screen_config...) dir = mktempdir() path = joinpath(dir, "$(gensym(:video)).$(format)") scene = get_scene(fig) - update_state_before_display!(fig) - screen = getscreen(backend, scene, GLNative; visible=visible, start_renderloop=false, screen_config...) + update && update_state_before_display!(fig) + config = Dict{Symbol,Any}(screen_config) + get!(config, :visible, visible) + get!(config, :start_renderloop, false) + screen = getscreen(backend, scene, config, GLNative) _xdim, _ydim = size(screen) xdim = iseven(_xdim) ? _xdim : _xdim + 1 ydim = iseven(_ydim) ? _ydim : _ydim + 1 buffer = Matrix{RGB{N0f8}}(undef, xdim, ydim) vso = VideoStreamOptions(format, framerate, compression, profile, pixel_format, loop, loglevel, "pipe:0", true) cmd = to_ffmpeg_cmd(vso, xdim, ydim) - process = @ffmpeg_env open(`$cmd $path`, "w") + process = open(`$(FFMPEG_jll.ffmpeg()) $cmd $path`, "w") return VideoStream(process.in, process, screen, buffer, abspath(path), vso) end @@ -277,10 +279,10 @@ function convert_video(input_path, output_path; video_options...) format = lstrip(typ, '.') vso = VideoStreamOptions(; format=format, input=input_path, rawvideo=false, video_options...) cmd = to_ffmpeg_cmd(vso) - @ffmpeg_env run(`$cmd $output_path`) + return run(`$(FFMPEG_jll.ffmpeg()) $cmd $output_path`) end function extract_frames(video, frame_folder; loglevel="quiet") path = joinpath(frame_folder, "frame%04d.png") - FFMPEG.ffmpeg_exe(`-loglevel $(loglevel) -i $video -y $path`) + run(`$(FFMPEG_jll.ffmpeg()) -loglevel $(loglevel) -i $video -y $path`) end diff --git a/src/figureplotting.jl b/src/figureplotting.jl index c11b3502a37..a1c5f53d2ca 100644 --- a/src/figureplotting.jl +++ b/src/figureplotting.jl @@ -1,8 +1,13 @@ struct AxisPlot - axis + axis::Any plot::AbstractPlot end +struct FigureAxis + figure::Figure + axis::Any +end + Base.show(io::IO, fap::FigureAxisPlot) = show(io, fap.figure) Base.show(io::IO, ::MIME"text/plain", fap::FigureAxisPlot) = print(io, "FigureAxisPlot()") @@ -17,8 +22,7 @@ function _validate_nt_like_keyword(@nospecialize(kw), name) The $name keyword argument received an unexpected value $(repr(kw)). The $name keyword expects a collection of Symbol => value pairs, such as NamedTuple, Attributes, or AbstractDict{Symbol}. The most common cause of this error is trying to create a one-element NamedTuple like (key = value) which instead creates a variable `key` with value `value`. - Write (key = value,) or (; key = value) instead.""" - )) + Write (key = value,) or (; key = value) instead.""")) end end @@ -28,102 +32,153 @@ function _disallow_keyword(kw, attributes) end end -function plot(P::PlotFunc, args...; axis = NamedTuple(), figure = NamedTuple(), kw_attributes...) +# For plots that dont require an axis, +# E.g. BlockSpec +struct FigureOnly end + + +function args_preferred_axis(::Type{<:Union{Wireframe,Surface,Contour3d}}, x::AbstractArray, y::AbstractArray, + z::AbstractArray) + return all(x -> z[1] ≈ x, z) ? Axis : LScene +end + +args_preferred_axis(x) = nothing + +function args_preferred_axis(@nospecialize(args...)) + # Fallback: check each single arg if they have a favorite axis type + for arg in args + r = args_preferred_axis(arg) + isnothing(r) || return r + end + return nothing +end + +args_preferred_axis(::AbstractVector, ::AbstractVector, ::AbstractVector, ::Function) = LScene +args_preferred_axis(::AbstractArray{T,3}) where {T} = LScene + +function args_preferred_axis(::AbstractVector{<:Union{AbstractGeometry{DIM},GeometryBasics.Mesh{DIM}}}) where {DIM} + return DIM === 2 ? Axis : LScene +end + +function args_preferred_axis(::Union{AbstractGeometry{DIM},GeometryBasics.Mesh{DIM}}) where {DIM} + return DIM === 2 ? Axis : LScene +end + +args_preferred_axis(::AbstractVector{<:Point3}) = LScene +args_preferred_axis(::AbstractVector{<:Point2}) = Axis + - _validate_nt_like_keyword(axis, "axis") - _validate_nt_like_keyword(figure, "figure") +preferred_axis_type(::Volume) = LScene +preferred_axis_type(::Union{Image,Heatmap}) = Axis - fig = Figure(; figure...) +function preferred_axis_type(p::Plot{F}) where F + # Otherwise, we check the arguments + input_args = map(to_value, p.args) + result = args_preferred_axis(Plot{F}, input_args...) + isnothing(result) || return result + conv_args = map(to_value, p.converted) + result = args_preferred_axis(Plot{F}, conv_args...) + isnothing(result) && return Axis # Fallback to Axis if nothing found + return result +end - axis = Dict(pairs(axis)) - if haskey(axis, :type) - axtype = axis[:type] - pop!(axis, :type) - ax = axtype(fig; axis...) +to_dict(dict::Dict) = dict +to_dict(nt::NamedTuple) = Dict{Symbol,Any}(pairs(nt)) +to_dict(attr::Attributes) = attributes(attr) + +function extract_attributes(dict, key) + attributes = pop!(dict, key, Dict{Symbol,Any}()) + _validate_nt_like_keyword(attributes, key) + return to_dict(attributes) +end + +function create_axis_for_plot(figure::Figure, plot::AbstractPlot, attributes::Dict) + axis_kw = extract_attributes(attributes, :axis) + AxType = if haskey(axis_kw, :type) + pop!(axis_kw, :type) else - proxyscene = Scene() - attrs = Attributes(kw_attributes) - delete!(attrs, :show_axis) - delete!(attrs, :limits) - plot!(proxyscene, P, attrs, args...) - if is2d(proxyscene) - ax = Axis(fig; axis...) - else - ax = LScene(fig; axis...) - end - empty!(proxyscene) + preferred_axis_type(plot) + end + if AxType == FigureOnly # For FigureSpec, which creates Axes dynamically + return nothing end + bbox = pop!(axis_kw, :bbox, nothing) + return _block(AxType, figure, [], axis_kw, bbox) +end - fig[1, 1] = ax - p = plot!(ax, P, Attributes(kw_attributes), args...) - FigureAxisPlot(fig, ax, p) +function create_axis_like(plot::AbstractPlot, attributes::Dict, ::Nothing) + figure_kw = extract_attributes(attributes, :figure) + figure = Figure(; figure_kw...) + ax = create_axis_for_plot(figure, plot, attributes) + if isnothing(ax) # For FigureSpec + return figure + else + figure[1, 1] = ax + return FigureAxis(figure, ax) + end end -# without scenelike, use current axis of current figure +MakieCore.create_axis_like!(@nospecialize(::AbstractPlot), attributes::Dict, s::Union{Plot, Scene}) = s -function plot!(P::PlotFunc, args...; kw_attributes...) +function MakieCore.create_axis_like!(@nospecialize(::AbstractPlot), attributes::Dict, ::Nothing) figure = current_figure() isnothing(figure) && error("There is no current figure to plot into.") + _disallow_keyword(:figure, attributes) ax = current_axis(figure) isnothing(ax) && error("There is no current axis to plot into.") - plot!(P, ax, args...; kw_attributes...) + _disallow_keyword(:axis, attributes) + return ax end -function plot(P::PlotFunc, gp::GridPosition, args...; axis = NamedTuple(), kwargs...) - _validate_nt_like_keyword(axis, "axis") - - f = get_top_parent(gp) +function MakieCore.create_axis_like!(@nospecialize(::AbstractPlot), attributes::Dict, gp::GridPosition) + _disallow_keyword(:figure, attributes) + c = contents(gp; exact=true) + if !(length(c) == 1 && can_be_current_axis(c[1])) + error("There needs to be a single axis-like object at $(gp.span), $(gp.side) to plot into.\nUse a non-mutating plotting command to create an axis implicitly.") + end + ax = first(c) + _disallow_keyword(:axis, attributes) + return ax +end - c = contents(gp, exact = true) +function create_axis_like(plot::AbstractPlot, attributes::Dict, gp::GridPosition) + _disallow_keyword(:figure, attributes) + figure = get_top_parent(gp) + c = contents(gp; exact=true) if !isempty(c) error(""" You have used the non-mutating plotting syntax with a GridPosition, which requires an empty GridLayout slot to create an axis in, but there are already the following objects at this layout position: - $(c) - If you meant to plot into an axis at this position, use the plotting function with `!` (e.g. `func!` instead of `func`). If you really want to place an axis on top of other blocks, make your intention clear and create it manually. """) end - - axis = Dict(pairs(axis)) - - if haskey(axis, :type) - axtype = axis[:type] - pop!(axis, :type) - ax = axtype(f; axis...) + ax = create_axis_for_plot(figure, plot, attributes) + if isnothing(ax) # For FigureSpec + return gp else - proxyscene = Scene() - plot!(proxyscene, P, Attributes(kwargs), args...) - if is2d(proxyscene) - ax = Axis(f; axis...) - else - ax = LScene(f; axis...) - end + gp[] = ax + return ax end - - gp[] = ax - p = plot!(P, ax, args...; kwargs...) - AxisPlot(ax, p) end -function plot!(P::PlotFunc, gp::GridPosition, args...; kwargs...) - - c = contents(gp, exact = true) +function MakieCore.create_axis_like!(@nospecialize(::AbstractPlot), attributes::Dict, gsp::GridSubposition) + _disallow_keyword(:figure, attributes) + layout = GridLayoutBase.get_layout_at!(gsp.parent; createmissing=false) + gp = layout[gsp.rows, gsp.cols, gsp.side] + c = contents(gp; exact=true) if !(length(c) == 1 && can_be_current_axis(c[1])) - error("There needs to be a single axis-like object at $(gp.span), $(gp.side) to plot into.\nUse a non-mutating plotting command to create an axis implicitly.") + error("There is not just one axis at $(gp).") end - ax = first(c) - plot!(P, ax, args...; kwargs...) + _disallow_keyword(:axis, attributes) + return first(c) end -function plot(P::PlotFunc, gsp::GridSubposition, args...; axis = NamedTuple(), kwargs...) - - _validate_nt_like_keyword(axis, "axis") - - layout = GridLayoutBase.get_layout_at!(gsp.parent, createmissing = true) - c = contents(gsp, exact = true) +function create_axis_like(plot::AbstractPlot, attributes::Dict, gsp::GridSubposition) + _disallow_keyword(:figure, attributes) + GridLayoutBase.get_layout_at!(gsp.parent; createmissing=true) + c = contents(gsp; exact=true) if !isempty(c) error(""" You have used the non-mutating plotting syntax with a GridSubposition, which requires an empty GridLayout slot to create an axis in, but there are already the following objects at this layout position: @@ -135,49 +190,147 @@ function plot(P::PlotFunc, gsp::GridSubposition, args...; axis = NamedTuple(), k """) end - fig = get_top_parent(gsp) + figure = get_top_parent(gsp) + ax = create_axis_for_plot(figure, plot, attributes) + gsp.parent[gsp.rows, gsp.cols, gsp.side] = ax + return ax +end - axis = Dict(pairs(axis)) +function create_axis_like!(@nospecialize(::AbstractPlot), attributes::Dict, ax::AbstractAxis) + _disallow_keyword(:axis, attributes) + return ax +end + +function create_axis_like(@nospecialize(::AbstractPlot), ::Dict, ::Union{Scene,AbstractAxis}) + return error("Plotting into an axis without !") +end + +figurelike_return(fa::FigureAxis, plot::AbstractPlot) = FigureAxisPlot(fa.figure, fa.axis, plot) +figurelike_return(ax::AbstractAxis, plot::AbstractPlot) = AxisPlot(ax, plot) +figurelike_return!(::AbstractAxis, plot::AbstractPlot) = plot +figurelike_return!(::Union{Plot, Scene}, plot::AbstractPlot) = plot + +update_state_before_display!(f::FigureAxisPlot) = update_state_before_display!(f.figure) + +function update_state_before_display!(f::Figure) + for c in f.content + update_state_before_display!(c) + end + return +end - if haskey(axis, :type) - axtype = axis[:type] - pop!(axis, :type) - ax = axtype(fig; axis...) - else - proxyscene = Scene() - plot!(proxyscene, P, Attributes(kwargs), args...) - if is2d(proxyscene) - ax = Axis(fig; axis...) - else - ax = LScene(fig; axis..., scenekw = (camera = automatic,)) + +@inline plot_args(args...) = (nothing, args) +@inline function plot_args(a::Union{Figure,AbstractAxis,Scene,Plot,GridSubposition,GridPosition}, + args...) + return (a, args) +end +function fig_keywords!(kws) + figkws = Dict{Symbol,Any}() + if haskey(kws, :axis) + figkws[:axis] = pop!(kws, :axis) + end + if haskey(kws, :figure) + figkws[:figure] = pop!(kws, :figure) + end + return figkws +end + +# Narrows down the default plotfunc early on, if `plot` is used +default_plot_func(f::F, args) where {F} = f +default_plot_func(::typeof(plot), args) = plotfunc(plottype(map(to_value, args)...)) + +# Don't inline these, since they will get called from `scatter!(args...; kw...)` which gets specialized to all kw args +@noinline function MakieCore._create_plot(F, attributes::Dict, args...) + figarg, pargs = plot_args(args...) + figkws = fig_keywords!(attributes) + plot = Plot{default_plot_func(F, pargs)}(pargs, attributes) + ax = create_axis_like(plot, figkws, figarg) + plot!(ax, plot) + return figurelike_return(ax, plot) +end + +@noinline function MakieCore._create_plot!(F, attributes::Dict, args...) + if length(args) > 0 + if args[1] isa FigureAxisPlot + throw(ArgumentError(""" + Tried plotting with `$(F)!` into a `FigureAxisPlot` object, this is not allowed. + + The `FigureAxisPlot` object is returned by plotting functions not ending in `!` like `lines(...)` or `scatter(...)`. + + It contains the new `Figure`, the new axis object, for example an `Axis`, `LScene` or `Axis3`, and the new plot object. It exists just as a convenience because returning it displays the contained figure. For all further operations, you should split it into its parts instead. This way, it is clear which of its components you are targeting. + + You can do this with the destructuring syntax `fig, ax, plt = some_plot(...)` and then continue, for example with `$(F)!(ax, ...)`. + """)) + end + if args[1] isa AxisPlot + throw(ArgumentError(""" + Tried plotting with `$(F)!` into a `AxisPlot` object, this is not allowed. + + The `AxisPlot` object is returned by plotting functions not ending in `!` with + a `GridPosition` as the first argument, like `lines(fig[1, 2], ...)` or `scatter(fig[1, 2], ...)`. + + It contains the new axis object, for example an `Axis`, `LScene` or `Axis3`, and the new plot object. For all further operations, you should split it into its parts instead. This way, it is clear which of its components you are targeting. + + You can do this with the destructuring syntax `ax, plt = some_plot(fig[1, 2], ...)` and then continue, for example with `$(F)!(ax, ...)`. + """)) end end + figarg, pargs = plot_args(args...) + figkws = fig_keywords!(attributes) + plot = Plot{default_plot_func(F, pargs)}(pargs, attributes) + ax = create_axis_like!(plot, figkws, figarg) + plot!(ax, plot) + return figurelike_return!(ax, plot) +end - gsp.parent[gsp.rows, gsp.cols, gsp.side] = ax - p = plot!(P, ax, args...; kwargs...) - AxisPlot(ax, p) +@noinline function MakieCore._create_plot!(F, attributes::Dict, scene::SceneLike, args...) + plot = Plot{default_plot_func(F, args)}(args, attributes) + plot!(scene, plot) + return plot end -function plot!(P::PlotFunc, gsp::GridSubposition, args...; kwargs...) +# This enables convert_arguments(::Type{<:AbstractPlot}, ::X) -> FigureSpec +# Which skips axis creation +# TODO, what to return for the dynamically created axes? +const PlotSpecPlot = Plot{plot, Tuple{<: GridLayoutSpec}} - layout = GridLayoutBase.get_layout_at!(gsp.parent, createmissing = false) +figurelike_return(f::GridPosition, p::PlotSpecPlot) = p +figurelike_return(f::Figure, p::PlotSpecPlot) = FigureAxisPlot(f, nothing, p) +MakieCore.create_axis_like!(::PlotSpecPlot, attributes::Dict, fig::Figure) = fig +MakieCore.create_axis_like!(::AbstractPlot, attributes::Dict, fig::Figure) = nothing - gp = layout[gsp.rows, gsp.cols, gsp.side] +# Axis interface - c = contents(gp, exact = true) - if !(length(c) == 1 && can_be_current_axis(c[1])) - error("There is not just one axis at $(gp).") +Makie.can_be_current_axis(ax::AbstractAxis) = true + +function update_state_before_display!(ax::AbstractAxis) + reset_limits!(ax) + return +end + +plot!(fa::FigureAxis, plot) = plot!(fa.axis, plot) + +function plot!(ax::AbstractAxis, plot::AbstractPlot) + plot!(ax.scene, plot) + # some area-like plots basically always look better if they cover the whole plot area. + # adjust the limit margins in those cases automatically. + needs_tight_limits(plot) && tightlimits!(ax) + if is_open_or_any_parent(ax.scene) + reset_limits!(ax) end - ax = first(c) - plot!(P, ax, args...; kwargs...) + return plot end -update_state_before_display!(f::FigureAxisPlot) = update_state_before_display!(f.figure) +function Base.delete!(ax::AbstractAxis, plot::AbstractPlot) + delete!(ax.scene, plot) + return ax +end -function update_state_before_display!(f::Figure) - for c in f.content - update_state_before_display!(c) +function Base.empty!(ax::AbstractAxis) + while !isempty(ax.scene.plots) + delete!(ax, ax.scene.plots[end]) end - return + return ax end diff --git a/src/figures.jl b/src/figures.jl index b732d08e427..b349ca71635 100644 --- a/src/figures.jl +++ b/src/figures.jl @@ -26,21 +26,42 @@ if an axis is placed at that position (if not it errors) or it can reference an get_scene(fig::Figure) = fig.scene get_scene(fap::FigureAxisPlot) = fap.figure.scene +get_scene(gp::GridLayoutBase.GridPosition) = get_scene(get_figure(gp)) +get_scene(gp::GridLayoutBase.GridSubposition) = get_scene(get_figure(gp)) + -const CURRENT_FIGURE = Ref{Union{Nothing, Figure}}(nothing) -Base.@deprecate_binding _current_figure CURRENT_FIGURE +const CURRENT_FIGURE = Ref{Union{Nothing, Figure}}(nothing) const CURRENT_FIGURE_LOCK = Base.ReentrantLock() -"Returns the current active figure (or the last figure that got created)" +""" + current_figure() + +Returns the current active figure (or the last figure created). +Returns `nothing` if there is no current active figure. +""" current_figure() = lock(()-> CURRENT_FIGURE[], CURRENT_FIGURE_LOCK) -"Set `fig` as the current active scene" + +""" + current_figure!(fig) + +Set `fig` as the current active figure. +""" current_figure!(fig) = lock(() -> (CURRENT_FIGURE[] = fig), CURRENT_FIGURE_LOCK) -"Returns the current active axis (or the last axis that got created)" +""" + current_axis() + +Returns the current active axis (or the last axis created). Returns `nothing` if there is no current active axis. +""" current_axis() = current_axis(current_figure()) +current_axis(::Nothing) = nothing current_axis(fig::Figure) = fig.current_axis[] -"Set `ax` as the current active axis in `fig`" +""" + current_axis!(fig::Figure, ax) + +Set `ax` as the current active axis in `fig`. +""" function current_axis!(fig::Figure, ax) if ax.parent !== fig error("This axis' parent is not the given figure") @@ -53,6 +74,11 @@ function current_axis!(fig::Figure, ::Nothing) fig.current_axis[] = nothing end +""" + current_axis!(ax) + +Set an axis `ax`, which must be part of a figure, as the figure's current active axis. +""" function current_axis!(ax) fig = ax.parent if !(fig isa Figure) @@ -73,13 +99,13 @@ function Figure(; kwargs...) kwargs_dict = Dict(kwargs) padding = pop!(kwargs_dict, :figure_padding, theme(:figure_padding)) - scene = Scene(; camera=campixel!, kwargs_dict...) + scene = Scene(; camera=campixel!, clear = true, kwargs_dict...) padding = convert(Observable{Any}, padding) - alignmode = lift(Outside ∘ to_rectsides, scene, padding) + alignmode = lift(Outside ∘ to_rectsides, padding) layout = GridLayout(scene) - on(scene, alignmode) do al + on(alignmode) do al layout.alignmode[] = al GridLayoutBase.update!(layout) end @@ -158,10 +184,7 @@ function resize_to_layout!(fig::Figure) end function Base.empty!(fig::Figure) - screens = copy(fig.scene.current_screens) empty!(fig.scene) - # The empty! api doesn't gracefully handle screens for e.g. the figure scene which is supposed to be still used! - append!(fig.scene.current_screens, screens) empty!(fig.scene.events) foreach(GridLayoutBase.remove_from_gridlayout!, reverse(fig.layout.content)) trim!(fig.layout) @@ -174,7 +197,7 @@ end # Layouts are already hooked up to this, so it's very simple. """ resize!(fig::Figure, width, height) -Resizes the given `Figure` to the resolution given by `width` and `height`. +Resizes the given `Figure` to the size given by `width` and `height`. If you want to resize the figure to its current layout content, use `resize_to_layout!(fig)` instead. """ -Makie.resize!(figure::Figure, args...) = resize!(figure.scene, args...) +Makie.resize!(figure::Figure, width::Integer, height::Integer) = resize!(figure.scene, width, height) diff --git a/src/interaction/events.jl b/src/interaction/events.jl index 9fedf07127f..fbc08f862a5 100644 --- a/src/interaction/events.jl +++ b/src/interaction/events.jl @@ -13,7 +13,7 @@ entered_window(scene, native_window) = not_implemented_for(native_window) function connect_screen(scene::Scene, screen) - on(screen.window_open) do open + on(scene, screen.window_open) do open events(scene).window_open[] = open end @@ -75,8 +75,6 @@ function onpick end ################################################################################ -abstract type BooleanOperator end - """ And(left, right[, rest...]) @@ -224,10 +222,10 @@ create_sets(s::Set) = [Set{Union{Keyboard.Button, Mouse.Button}}(s)] # ispressed and logic evaluation """ - ispressed(parent, result::Bool) - ispressed(parent, button::Union{Mouse.Button, Keyboard.Button) - ispressed(parent, collection::Union{Set, Vector, Tuple}) - ispressed(parent, op::BooleanOperator) +ispressed(parent, result::Bool[, waspressed = nothing]) +ispressed(parent, button::Union{Mouse.Button, Keyboard.Button[, waspressed = nothing]) + ispressed(parent, collection::Union{Set, Vector, Tuple}[, waspressed = nothing]) + ispressed(parent, op::BooleanOperator[, waspressed = nothing]) This function checks if a button or combination of buttons is pressed. @@ -251,25 +249,31 @@ Furthermore you can also make any button, button collection or boolean expression exclusive by wrapping it in `Exclusively(...)`. With that `ispressed` will only return true if the currently pressed buttons match the request exactly. -See also: [`And`](@ref), [`Or`](@ref), [`Not`](@ref), [`Exclusively`](@ref), +For cases where you want to react to a release event you can optionally add +a key or mousebutton `waspressed` which is then assumed to be pressed regardless +of it's current state. For example, when reacting to a mousebutton event, you can +pass `event.button` so that a key combination including that button still evaluates +as true. + +See also: [`waspressed`](@ref) [`And`](@ref), [`Or`](@ref), [`Not`](@ref), [`Exclusively`](@ref), [`&`](@ref), [`|`](@ref), [`!`](@ref) """ -ispressed(events::Events, mb::Mouse.Button) = mb in events.mousebuttonstate -ispressed(events::Events, key::Keyboard.Button) = key in events.keyboardstate -ispressed(parent, result::Bool) = result +ispressed(events::Events, mb::Mouse.Button, waspressed = nothing) = mb in events.mousebuttonstate || mb == waspressed +ispressed(events::Events, key::Keyboard.Button, waspressed = nothing) = key in events.keyboardstate || key == waspressed +ispressed(parent, result::Bool, waspressed = nothing) = result -ispressed(parent, mb::Mouse.Button) = ispressed(events(parent), mb) -ispressed(parent, key::Keyboard.Button) = ispressed(events(parent), key) -@deprecate ispressed(scene, ::Nothing) ispressed(parent, true) +ispressed(parent, mb::Mouse.Button, waspressed = nothing) = ispressed(events(parent), mb, waspressed) +ispressed(parent, key::Keyboard.Button, waspressed = nothing) = ispressed(events(parent), key, waspressed) # Boolean Operator evaluation -ispressed(parent, op::And) = ispressed(parent, op.left) && ispressed(parent, op.right) -ispressed(parent, op::Or) = ispressed(parent, op.left) || ispressed(parent, op.right) -ispressed(parent, op::Not) = !ispressed(parent, op.x) -ispressed(parent, op::Exclusively) = ispressed(events(parent), op) -ispressed(e::Events, op::Exclusively) = op.x == union(e.keyboardstate, e.mousebuttonstate) +ispressed(parent, op::And, waspressed = nothing) = ispressed(parent, op.left, waspressed) && ispressed(parent, op.right, waspressed) +ispressed(parent, op::Or, waspressed = nothing) = ispressed(parent, op.left, waspressed) || ispressed(parent, op.right, waspressed) +ispressed(parent, op::Not, waspressed = nothing) = !ispressed(parent, op.x, waspressed) +ispressed(parent, op::Exclusively, waspressed = nothing) = ispressed(events(parent), op, waspressed) +ispressed(e::Events, op::Exclusively, waspressed::Union{Mouse.Button, Keyboard.Button}) = op.x == union(e.keyboardstate, e.mousebuttonstate, waspressed) +ispressed(e::Events, op::Exclusively, waspressed = nothing) = op.x == union(e.keyboardstate, e.mousebuttonstate) # collections -ispressed(parent, set::Set) = all(x -> ispressed(parent, x), set) -ispressed(parent, set::Vector) = all(x -> ispressed(parent, x), set) -ispressed(parent, set::Tuple) = all(x -> ispressed(parent, x), set) +ispressed(parent, set::Set, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) +ispressed(parent, set::Vector, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) +ispressed(parent, set::Tuple, waspressed = nothing) = all(x -> ispressed(parent, x, waspressed), set) diff --git a/src/interaction/inspector.jl b/src/interaction/inspector.jl index e7cfa61eb67..5a536e4c56e 100644 --- a/src/interaction/inspector.jl +++ b/src/interaction/inspector.jl @@ -125,12 +125,12 @@ A --- B | | D --- C -this computes parameter `f` such that the line from `A + f * (B - A)` to -`D + f * (C - D)` crosses through the given point `P`. This assumes that `P` is +this computes parameter `f` such that the line from `A + f * (B - A)` to +`D + f * (C - D)` crosses through the given point `P`. This assumes that `P` is inside the quad and that none of the edges cross. """ function point_in_quad_parameter( - A::Point2, B::Point2, C::Point2, D::Point2, P::Point2; + A::Point2, B::Point2, C::Point2, D::Point2, P::Point2; iterations = 50, epsilon = 1e-6 ) @@ -166,9 +166,9 @@ end function shift_project(scene, pos) project( camera(scene).projectionview[], - Vec2f(widths(pixelarea(scene)[])), + Vec2f(size(scene)), pos - ) .+ Vec2f(origin(pixelarea(scene)[])) + ) .+ Vec2f(origin(viewport(scene)[])) end @@ -219,7 +219,7 @@ disable!(inspector::DataInspector) = inspector.attributes.enabled[] = false Creates a data inspector which will show relevant information in a tooltip when you hover over a plot. -This functionality can eb disabled on a per-plot basis by setting +This functionality can be disabled on a per-plot basis by setting `plot.inspectable[] = false`. The displayed text can be adjusted by setting `plot.inspector_label` to a function `(plot, index, position) -> "my_label"` returning a label. See Makie documentation for more detail. @@ -236,7 +236,7 @@ returning a label. See Makie documentation for more detail. - `enable_indicators = true)`: Enables or disables indicators - `depth = 9e3`: Depth value of the tooltip. This should be high so that the tooltip is always in front. -- `apply_tooltip_offset = true`: Enables or disables offsetting tooltips based +- `apply_tooltip_offset = true`: Enables or disables offsetting tooltips based on, for example, markersize. - and all attributes from `Tooltip` """ @@ -246,7 +246,7 @@ end function DataInspector(scene::Scene; priority = 100, kwargs...) parent = root(scene) - @assert origin(pixelarea(parent)[]) == Vec2f(0) + @assert origin(viewport(parent)[]) == Vec2f(0) attrib_dict = Dict(kwargs) base_attrib = Attributes( @@ -371,7 +371,12 @@ end # clears temporary plots (i.e. bboxes) and update selection function clear_temporary_plots!(inspector::DataInspector, plot) - inspector.selection = plot + if inspector.selection !== plot + if haskey(inspector.selection, :inspector_clear) + inspector.selection[:inspector_clear][](inspector, inspector.selection) + end + inspector.selection = plot + end for i in length(inspector.obsfuncs):-1:3 off(pop!(inspector.obsfuncs)) @@ -397,7 +402,7 @@ end function update_tooltip_alignment!(inspector, proj_pos) inspector.plot[1][] = proj_pos - wx, wy = widths(pixelarea(inspector.root)[]) + wx, wy = widths(viewport(inspector.root)[]) px, py = proj_pos placement = py < 0.75wy ? (:above) : (:below) @@ -422,8 +427,8 @@ function show_data(inspector::DataInspector, plot::Scatter, idx) tt = inspector.plot scene = parent_scene(plot) - pos = position_on_plot(plot, idx) - proj_pos = shift_project(scene, pos) + pos = position_on_plot(plot, idx, apply_transform = false) + proj_pos = shift_project(scene, apply_transform_and_model(plot, pos)) update_tooltip_alignment!(inspector, proj_pos) if haskey(plot, :inspector_label) @@ -489,8 +494,8 @@ function show_data(inspector::DataInspector, plot::MeshScatter, idx) a.indicator_visible[] = true end - pos = position_on_plot(plot, idx) - proj_pos = shift_project(scene, pos) + pos = position_on_plot(plot, idx, apply_transform = false) + proj_pos = shift_project(scene, apply_transform_and_model(plot, pos)) update_tooltip_alignment!(inspector, proj_pos) if haskey(plot, :inspector_label) @@ -510,14 +515,13 @@ function show_data(inspector::DataInspector, plot::Union{Lines, LineSegments}, i scene = parent_scene(plot) # cast ray from cursor into screen, find closest point to line - pos = position_on_plot(plot, idx) - - proj_pos = shift_project(scene, pos) + pos = position_on_plot(plot, idx, apply_transform = false) + proj_pos = shift_project(scene, apply_transform_and_model(plot, pos)) update_tooltip_alignment!(inspector, proj_pos) tt.offset[] = ifelse( - a.apply_tooltip_offset[], - sv_getindex(plot.linewidth[], idx) + 2, + a.apply_tooltip_offset[], + sv_getindex(plot.linewidth[], idx) + 2, a.offset[] ) @@ -561,7 +565,7 @@ function show_data(inspector::DataInspector, plot::Mesh, idx) end p = wireframe!( - scene, bbox, color = a.indicator_color, + scene, bbox, color = a.indicator_color, transformation = Transformation(), linewidth = a.indicator_linewidth, linestyle = a.indicator_linestyle, visible = a.indicator_visible, inspectable = false @@ -597,7 +601,7 @@ function show_data(inspector::DataInspector, plot::Surface, idx) proj_pos = Point2f(mouseposition_px(inspector.root)) update_tooltip_alignment!(inspector, proj_pos) - pos = position_on_plot(plot, idx) + pos = position_on_plot(plot, idx, apply_transform = false) if !isnan(pos) tt[1][] = proj_pos @@ -654,7 +658,8 @@ function show_imagelike(inspector, plot, name, edge_based) end if haskey(plot, :inspector_label) - tt.text[] = plot[:inspector_label][](plot, (i, j), Point3f(pos[1], pos[2], z)) + ins_p = z isa Colorant ? (pos[1], pos[2], z) : Point3f(pos[1], pos[2], z) + tt.text[] = plot[:inspector_label][](plot, (i, j), ins_p) else tt.text[] = color2text(name, x, y, z) end @@ -662,7 +667,7 @@ function show_imagelike(inspector, plot, name, edge_based) a._color[] = if z isa AbstractFloat interpolated_getindex( to_colormap(plot.colormap[]), z, - to_value(get(plot.attributes, :colorrange, (0, 1))) + extract_colorrange(plot) ) else z @@ -673,16 +678,16 @@ function show_imagelike(inspector, plot, name, edge_based) if a.enable_indicators[] if plot.interpolate[] - if inspector.selection != plot || (length(inspector.temp_plots) != 1) || + if inspector.selection != plot || (length(inspector.temp_plots) != 1) || !(inspector.temp_plots[1] isa Scatter) clear_temporary_plots!(inspector, plot) p = scatter!( scene, pos, color = a._color, visible = a.indicator_visible, inspectable = false, model = plot.model, - # TODO switch to Rect with 2r-1 or 2r-2 markersize to have + # TODO switch to Rect with 2r-1 or 2r-2 markersize to have # just enough space to always detect the underlying image - marker=:rect, markersize = map(r -> 2r, a.range), + marker=:rect, markersize = map(r -> 2r, a.range), strokecolor = a.indicator_color, strokewidth = a.indicator_linewidth, depth_shift = -1f-3 @@ -695,7 +700,7 @@ function show_imagelike(inspector, plot, name, edge_based) end else bbox = _pixelated_image_bbox(plot[1][], plot[2][], plot[3][], i, j, edge_based) - if inspector.selection != plot || (length(inspector.temp_plots) != 1) || + if inspector.selection != plot || (length(inspector.temp_plots) != 1) || !(inspector.temp_plots[1] isa Wireframe) clear_temporary_plots!(inspector, plot) p = wireframe!( @@ -791,7 +796,7 @@ end ################################################################################ -### show_data for Combined/recipe plots +### show_data for Plot/recipe plots ################################################################################ @@ -811,8 +816,8 @@ function show_data(inspector::DataInspector, plot::BarPlot, idx) tt = inspector.plot scene = parent_scene(plot) - pos = apply_transform_and_model(plot, plot[1][][idx]) - proj_pos = shift_project(scene, to_ndim(Point3f, pos, 0)) + pos = plot[1][][idx] + proj_pos = shift_project(scene, apply_transform_and_model(plot, to_ndim(Point3f, pos, 0))) update_tooltip_alignment!(inspector, proj_pos) if a.enable_indicators[] @@ -853,7 +858,7 @@ end function show_data(inspector::DataInspector, plot::Arrows, idx, source) a = inspector.attributes tt = inspector.plot - pos = apply_transform_and_model(plot, plot[1][][idx]) + pos = plot[1][][idx] mpos = Point2f(mouseposition_px(inspector.root)) update_tooltip_alignment!(inspector, mpos) @@ -919,7 +924,7 @@ function show_poly(inspector, plot, poly, idx, source) clear_temporary_plots!(inspector, plot) p = lines!( - scene, line_collection, color = a.indicator_color, + scene, line_collection, color = a.indicator_color, transformation = Transformation(source), strokewidth = a.indicator_linewidth, linestyle = a.indicator_linestyle, visible = a.indicator_visible, inspectable = false, depth_shift = -1f-3 @@ -954,7 +959,7 @@ function show_data(inspector::DataInspector, plot::VolumeSlices, idx, child::Hea proj_pos = Point2f(mouseposition_px(inspector.root)) update_tooltip_alignment!(inspector, proj_pos) tt[1][] = proj_pos - + world_pos = apply_transform_and_model(child, pos) if haskey(plot, :inspector_label) @@ -1004,7 +1009,7 @@ function show_data(inspector::DataInspector, plot::Band, idx::Integer, mesh::Mes clear_temporary_plots!(inspector, plot) p = lines!( scene, [P1, P2], transformation = Transformation(plot.transformation), - color = a.indicator_color, strokewidth = a.indicator_linewidth, + color = a.indicator_color, strokewidth = a.indicator_linewidth, linestyle = a.indicator_linestyle, visible = a.indicator_visible, inspectable = false, depth_shift = -1f-3 diff --git a/src/interaction/interactive_api.jl b/src/interaction/interactive_api.jl index 7ea6243d497..4fea246ad69 100644 --- a/src/interaction/interactive_api.jl +++ b/src/interaction/interactive_api.jl @@ -31,8 +31,6 @@ function onpick(f, scene::Scene, plots::AbstractPlot...; range=1) end end -@deprecate mouse_selection pick - """ mouse_in_scene(fig/ax/scene[, priority = 0]) @@ -44,7 +42,7 @@ function mouse_in_scene(scene::Scene; priority = 0) p = rootparent(scene) output = Observable(Vec2(0.0)) on(events(scene).mouseposition, priority = priority) do mp - output[] = Vec(mp) .- minimum(pixelarea(scene)[]) + output[] = Vec(mp) .- minimum(viewport(scene)[]) return Consume(false) end output @@ -175,7 +173,7 @@ Normalizes mouse position `pos` relative to the screen rectangle. """ screen_relative(x, mpos) = screen_relative(get_scene(x), mpos) function screen_relative(scene::Scene, mpos) - return Point2f(mpos) .- Point2f(minimum(pixelarea(scene)[])) + return Point2f(mpos) .- Point2f(minimum(viewport(scene)[])) end """ diff --git a/src/interaction/iodevices.jl b/src/interaction/iodevices.jl index 416f8580f69..4d6d2855d1d 100644 --- a/src/interaction/iodevices.jl +++ b/src/interaction/iodevices.jl @@ -170,6 +170,11 @@ module Mouse middle = 2 right = 1 # Conform to GLFW none = -1 # for convenience + button_4 = 3 + button_5 = 4 + button_6 = 5 + button_7 = 6 + button_8 = 7 end """ diff --git a/src/interaction/observables.jl b/src/interaction/observables.jl index 42d7fde312e..6b384efa4d0 100644 --- a/src/interaction/observables.jl +++ b/src/interaction/observables.jl @@ -17,28 +17,66 @@ function safe_off(o::Observables.AbstractObservable, f) end end -""" - map_once(closure, inputs::Observable....)::Observable +function on_latest(f, observable::Observable; update=false, spawn=false) + return on_latest(f, nothing, observable; update=update, spawn=spawn) +end + +function on_latest(f, to_track, observable::Observable; update=false, spawn=false) + last_task = nothing + has_changed = Threads.Atomic{Bool}(false) + function run_f(new_value) + try + f(new_value) + catch e + @warn "Error in f" exception=(e, Base.catch_backtrace()) + end + # Since we skip updates completely while executing the above `f` + # We need to check after finishing, if the value has changed! + # `==` can be pretty expensive or ill defined, so we use a flag `has_changed` + # But `==` would be better, considering, that one could arrive at an old value. + # This should be configurable, but since async_latest is needed for working on big data as input + # we assume for now that `==` is prohibitive as the default + if has_changed[] + has_changed[] = false + run_f(observable[]) # needs to be recursive + end + end + + function on_callback(new_value) + if isnothing(last_task) || istaskdone(last_task) + if spawn + last_task = Threads.@spawn run_f(new_value) + else + last_task = Threads.@async run_f(new_value) + end + else + has_changed[] = true + return # Do nothing if working + end + end -Like Reactive.foreach, in the sense that it will be preserved even if no reference is kept. -The difference is, that you can call map once multiple times with the same closure and it will -close the old result Observable and register a new one instead. + update && f(observable[]) + + if isnothing(to_track) + return on(on_callback, observable) + else + return on(on_callback, to_track, observable) + end +end -``` -function test(s1::Observable) - s3 = map_once(x-> (println("1 ", x); x), s1) - s3 = map_once(x-> (println("2 ", x); x), s1) +function onany_latest(f, observables...; update=false, spawn=false) + result = Observable{Any}(map(to_value, observables)) + onany((args...)-> (result[] = args), observables...) + on_latest((args)-> f(args...), result; update=update, spawn=spawn) +end +function map_latest!(f, result::Observable, observables...; update=false, spawn=false) + callback = Observables.MapCallback(f, result, observables) + return onany_latest(callback, observables...; update=update, spawn=spawn) end -test(Observable(1), Observable(2)) -> -""" -function map_once( - f, input::Observable, inputrest::Observable... - ) - for arg in (input, inputrest...) - safe_off(arg, f) - end - lift(f, input, inputrest...) +function map_latest(f, observables...; spawn=false, ignore_equal_values=false) + result = Observable(f(map(to_value, observables)...); ignore_equal_values=ignore_equal_values) + map_latest!(f, result, observables...; update=update, spawn=spawn) + return result end diff --git a/src/interaction/ray_casting.jl b/src/interaction/ray_casting.jl index f87ffe4bae3..75f52c77e18 100644 --- a/src/interaction/ray_casting.jl +++ b/src/interaction/ray_casting.jl @@ -21,7 +21,7 @@ end Ray(scene[, cam = cameracontrols(scene)], xy) Returns a `Ray` into the given `scene` passing through pixel position `xy`. Note -that the pixel position should be relative to the origin of the scene, as it is +that the pixel position should be relative to the origin of the scene, as it is when calling `mouseposition_px(scene)`. """ Ray(scene::Scene, xy::VecTypes{2}) = Ray(scene, cameracontrols(scene), xy) @@ -35,12 +35,12 @@ function Ray(scene::Scene, cam::Camera3D, xy::VecTypes{2}) u_z = normalize(viewdir) u_x = normalize(cross(u_z, cam.upvector[])) u_y = normalize(cross(u_x, u_z)) - - px_width, px_height = widths(scene.px_area[]) + + px_width, px_height = widths(scene) aspect = px_width / px_height rel_pos = 2 .* xy ./ (px_width, px_height) .- 1 - if cam.attributes.projectiontype[] === Perspective + if cam.settings.projectiontype[] === Perspective dir = (rel_pos[1] * aspect * u_x + rel_pos[2] * u_y) * tand(0.5 * cam.fov[]) + u_z return Ray(cam.eyeposition[], normalize(dir)) else @@ -51,7 +51,7 @@ function Ray(scene::Scene, cam::Camera3D, xy::VecTypes{2}) end function Ray(scene::Scene, cam::Camera2D, xy::VecTypes{2}) - rel_pos = xy ./ widths(scene.px_area[]) + rel_pos = xy ./ widths(scene) pv = scene.camera.projectionview[] m = Vec2f(pv[1, 1], pv[2, 2]) b = Vec2f(pv[1, 4], pv[2, 4]) @@ -64,16 +64,16 @@ function Ray(::Scene, ::PixelCamera, xy::VecTypes{2}) end function Ray(scene::Scene, ::RelativeCamera, xy::VecTypes{2}) - origin = xy ./ widths(scene.px_area[]) + origin = xy ./ widths(scene) return Ray(to_ndim(Point3f, origin, 10_000f0), Vec3f(0,0,-1)) end Ray(scene::Scene, cam, xy::VecTypes{2}) = ray_from_projectionview(scene, xy) -# This method should always work +# This method should always work function ray_from_projectionview(scene::Scene, xy::VecTypes{2}) inv_view_proj = inv(camera(scene).projectionview[]) - area = pixelarea(scene)[] + area = viewport(scene)[] # This figures out the camera view direction from the projectionview matrix # and computes a ray from a near and a far point. @@ -124,6 +124,12 @@ function closest_point_on_line(A::Point3f, B::Point3f, ray::Ray) return A .+ clamp(t, 0.0, AB_norm) * u_AB end +function ray_triangle_intersection(A::VecTypes, B::VecTypes, C::VecTypes, ray::Ray, ϵ = 1e-6) + return ray_triangle_intersection( + to_ndim(Point3f, A, 0f0), to_ndim(Point3f, B, 0f0), to_ndim(Point3f, C, 0f0), + ray, ϵ + ) +end function ray_triangle_intersection(A::VecTypes{3}, B::VecTypes{3}, C::VecTypes{3}, ray::Ray, ϵ = 1e-6) # See: https://www.iue.tuwien.ac.at/phd/ertl/node114.html @@ -182,7 +188,7 @@ This function performs a `pick` at the given pixel position `xy` and returns the picked `plot`, `index` and world or input space `position::Point3f`. It is equivalent to ``` plot, idx = pick(fig/ax/scene, xy) -ray = Ray(parent_scene(plot), xy .- minimum(pixelarea(parent_scene(plot))[])) +ray = Ray(parent_scene(plot), xy .- minimum(viewport(parent_scene(plot))[])) position = position_on_plot(plot, idx, ray, apply_transform = true) ``` See [`position_on_plot`](@ref) for more information. @@ -191,7 +197,7 @@ function ray_assisted_pick(obj, xy = events(obj).mouseposition[]; apply_transfor plot, idx = pick(get_scene(obj), xy) isnothing(plot) && return (plot, idx, Point3f(NaN)) scene = parent_scene(plot) - ray = Ray(scene, xy .- minimum(pixelarea(scene)[])) + ray = Ray(scene, xy .- minimum(viewport(scene)[])) pos = position_on_plot(plot, idx, ray, apply_transform = apply_transform) return (plot, idx, pos) end @@ -200,23 +206,23 @@ end """ position_on_plot(plot, index[, ray::Ray; apply_transform = true]) -This function calculates the world or input space position of a ray - plot -intersection with the result `plot, idx = pick(...)` and a ray cast from the +This function calculates the world or input space position of a ray - plot +intersection with the result `plot, idx = pick(...)` and a ray cast from the picked position. If there is no intersection `Point3f(NaN)` will be returned. This should be called as ``` plot, idx = pick(ax, px_pos) -pos_in_ax = position_on_plot(plot, idx, Ray(ax, px_pos .- minimum(pixelarea(ax.scene)[]))) +pos_in_ax = position_on_plot(plot, idx, Ray(ax, px_pos .- minimum(viewport(ax.scene)[]))) ``` or more simply `plot, idx, pos_in_ax = ray_assisted_pick(ax, px_pos)`. -You can switch between getting a position in world space (after applying -transformations like `log`, `translate!()`, `rotate!()` and `scale!()`) and +You can switch between getting a position in world space (after applying +transformations like `log`, `translate!()`, `rotate!()` and `scale!()`) and input space (the raw position data of the plot) by adjusting `apply_transform`. -Note that `position_on_plot` is only implemented for primitive plot types, i.e. -the possible return types of `pick`. Depending on the plot type the calculation +Note that `position_on_plot` is only implemented for primitive plot types, i.e. +the possible return types of `pick`. Depending on the plot type the calculation differs: - `scatter` and `meshscatter` return the position of the picked marker/mesh - `text` is excluded, always returning `Point3f(NaN)` @@ -227,26 +233,31 @@ differs: """ function position_on_plot(plot::AbstractPlot, idx::Integer; apply_transform = true) return position_on_plot( - plot, idx, ray_at_cursor(parent_scene(plot)); + plot, idx, ray_at_cursor(parent_scene(plot)); apply_transform = apply_transform ) end function position_on_plot(plot::Union{Scatter, MeshScatter}, idx, ray::Ray; apply_transform = true) - pos = to_ndim(Point3f, plot[1][][idx], 0f0) - if apply_transform && !isnan(pos) - return apply_transform_and_model(plot, pos) + point = plot[1][][idx] + point3f = to_ndim(Point3f, point, 0.0f0) + point_t = if apply_transform && !isnan(point3f) + apply_transform_and_model(plot, point3f) else - return pos + point3f end + return to_ndim(typeof(point), point_t, 0.0f0) end function position_on_plot(plot::Union{Lines, LineSegments}, idx, ray::Ray; apply_transform = true) - p0, p1 = apply_transform_and_model(plot, plot[1][][idx-1:idx]) + if idx == 1 + idx = 2 + end + p0, p1 = apply_transform_and_model(plot, plot[1][][(idx-1):idx]) pos = closest_point_on_line(p0, p1, ray) - + if apply_transform return pos else @@ -258,8 +269,8 @@ function position_on_plot(plot::Union{Lines, LineSegments}, idx, ray::Ray; apply end function position_on_plot(plot::Union{Heatmap, Image}, idx, ray::Ray; apply_transform = true) - # Heatmap and Image are always a Rect2f. The transform function is currently - # not allowed to change this, so applying it should be fine. Applying the + # Heatmap and Image are always a Rect2f. The transform function is currently + # not allowed to change this, so applying it should be fine. Applying the # model matrix may add a z component to the Rect2f, which we can't represent. # So we instead inverse-transform the ray space = to_value(get(plot, :space, :data)) @@ -268,7 +279,7 @@ function position_on_plot(plot::Union{Heatmap, Image}, idx, ray::Ray; apply_tran end ray = transform(inv(plot.model[]), ray) pos = ray_rect_intersection(Rect2f(p0, p1 - p0), ray) - + if apply_transform p4d = plot.model[] * to_ndim(Point4f, to_ndim(Point3f, pos, 0), 1) return p4d[Vec(1, 2, 3)] / p4d[4] @@ -300,7 +311,7 @@ function position_on_plot(plot::Mesh, idx, ray::Ray; apply_transform = true) end end - @info "Did not find $idx" + @debug "Did not find intersection for index = $idx when casting a ray on mesh." return Point3f(NaN) end @@ -329,7 +340,7 @@ function position_on_plot(plot::Surface, idx, ray::Ray; apply_transform = true) ray = transform(inv(plot.model[]), ray) tf = transform_func(plot) space = to_value(get(plot, :space, :data)) - + # This isn't the most accurate so we include some neighboring faces pos = Point3f(NaN) for i in _i-1:_i+1, j in _j-1:_j+1 @@ -387,4 +398,4 @@ function position_on_plot(plot::Volume, idx, ray::Ray; apply_transform = true) end position_on_plot(plot::Text, args...; kwargs...) = Point3f(NaN) -position_on_plot(plot::Nothing, args...; kwargs...) = Point3f(NaN) \ No newline at end of file +position_on_plot(plot::Nothing, args...; kwargs...) = Point3f(NaN) diff --git a/src/interfaces.jl b/src/interfaces.jl index f08358613bf..19c35941eb0 100644 --- a/src/interfaces.jl +++ b/src/interfaces.jl @@ -1,68 +1,43 @@ -function default_theme(scene) - return Attributes( - transformation = automatic, - model = automatic, - visible = true, - transparency = false, - overdraw = false, - ssao = false, - inspectable = true, - depth_shift = 0f0, - space = :data - ) + +function add_cycle_attribute!(plot::Plot, scene::Scene, cycle=get_cycle_for_plottype(plot.cycle[])) + cycler = scene.cycler + palette = scene.theme.palette + add_cycle_attributes!(plot, cycle, cycler, palette) + return end -function color_and_colormap!(plot, intensity = plot[:color]) - if intensity[] isa Union{Number, AbstractArray{<: Number}} - @converted_attribute plot (colormap,) - replace_automatic!(plot, :highclip) do - lift(last, plot, colormap) - end - replace_automatic!(plot, :lowclip) do - lift(first, plot, colormap) - end - get!(plot, :nan_color, RGBAf(0,0,0,0)) - if intensity[] isa Number - plot[:colorrange][] isa Automatic && - error("Cannot determine a colorrange automatically for single number color value $intensity. Pass an explicit colorrange.") - args = @converted_attribute plot (colorrange, lowclip, highclip, nan_color) - plot[:color] = lift(numbers_to_colors, plot, intensity, colormap, args...) - delete!(plot, :colorrange) - delete!(plot, :colormap) - elseif intensity[] isa AbstractArray{<: Number} - haskey(plot, :colormap) || error("Plot $(typeof(plot)) needs to have a colormap to allow the attribute color to be an array of numbers") - replace_automatic!(plot, :colorrange) do - lift(x-> Vec2f(distinct_extrema_nan(x)), plot, intensity) - end - end - return true - else - delete!(plot, :highclip) - delete!(plot, :lowclip) - delete!(plot, :colorrange) - delete!(plot, :colormap) - return false +function color_and_colormap!(plot, colors = plot.color) + scene = parent_scene(plot) + if !isnothing(scene) && haskey(plot, :cycle) + add_cycle_attribute!(plot, scene) end + colors = assemble_colors(colors[], colors, plot) + attributes(plot.attributes)[:calculated_colors] = colors end -function calculated_attributes!(T::Type{<: Mesh}, plot) +function calculated_attributes!(::Type{<: AbstractPlot}, plot) + scene = parent_scene(plot) + if !isnothing(scene) && haskey(plot, :cycle) + add_cycle_attribute!(plot, scene) + end +end + +function calculated_attributes!(::Type{<: Mesh}, plot) mesha = lift(GeometryBasics.attributes, plot, plot.mesh) color = haskey(mesha[], :color) ? lift(x-> x[:color], plot, mesha) : plot.color - need_cmap = color_and_colormap!(plot, color) - need_cmap || delete!(plot, :colormap) + color_and_colormap!(plot, color) return end function calculated_attributes!(::Type{<: Union{Heatmap, Image}}, plot) - plot[:color] = plot[3] - color_and_colormap!(plot) + color_and_colormap!(plot, plot[3]) end function calculated_attributes!(::Type{<: Surface}, plot) colors = plot[3] if haskey(plot, :color) color = plot[:color][] - if isa(color, AbstractMatrix{<: Number}) && !(color === to_value(colors)) + if isa(color, AbstractMatrix) && !(color === to_value(colors)) colors = plot[:color] end end @@ -71,10 +46,17 @@ end function calculated_attributes!(::Type{<: MeshScatter}, plot) color_and_colormap!(plot) + return +end + +function calculated_attributes!(::Type{<:Volume}, plot) + color_and_colormap!(plot, plot[4]) + return end function calculated_attributes!(::Type{<:Text}, plot) - return color_and_colormap!(plot) + color_and_colormap!(plot) + return end function calculated_attributes!(::Type{<: Scatter}, plot) @@ -100,31 +82,73 @@ function calculated_attributes!(::Type{<: Scatter}, plot) end function calculated_attributes!(::Type{T}, plot) where {T<:Union{Lines, LineSegments}} - color_and_colormap!(plot) pos = plot[1][] # extend one color/linewidth per linesegment to be one (the same) color/linewidth per vertex if T <: LineSegments for attr in [:color, :linewidth] # taken from @edljk in PR #77 if haskey(plot, attr) && isa(plot[attr][], AbstractVector) && (length(pos) ÷ 2) == length(plot[attr][]) - plot[attr] = lift(plot, plot[attr]) do cols + # TODO, this is actually buggy for `plot.color = new_colors`, since we're overwriting the origin color input + attributes(plot.attributes)[attr] = lift(plot, plot[attr]) do cols map(i -> cols[(i + 1) ÷ 2], 1:(length(cols) * 2)) end end end end + color_and_colormap!(plot) + return end -const atomic_function_symbols = ( - :text, :meshscatter, :scatter, :mesh, :linesegments, - :lines, :surface, :volume, :heatmap, :image +const atomic_functions = ( + text, meshscatter, scatter, mesh, linesegments, + lines, surface, volume, heatmap, image ) +const Atomic{Arg} = Union{map(x-> Plot{x, Arg}, atomic_functions)...} + +function convert_arguments!(plot::Plot{F}) where {F} + P = Plot{F,Any} + function on_update(kw, args...) + nt = convert_arguments(P, args...; kw...) + pnew, converted = apply_convert!(P, plot.attributes, nt) + @assert plotfunc(pnew) === F "Changed the plot type in convert_arguments. This isn't allowed!" + for (obs, new_val) in zip(plot.converted, converted) + obs[] = new_val + end + end + used_attrs = used_attributes(P, to_value.(plot.args)...) + convert_keys = intersect(used_attrs, keys(plot.attributes)) + kw_signal = if isempty(convert_keys) + # lift(f) isn't supported so we need to catch the empty case + Observable(()) + else + # Remove used attributes from `attributes` and collect them in a `Tuple` to pass them more easily + lift((args...) -> Pair.(convert_keys, args), plot, pop!.(plot.attributes, convert_keys)...) + end + onany(on_update, plot, kw_signal, plot.args...) + return +end -const atomic_functions = getfield.(Ref(Makie), atomic_function_symbols) -const Atomic{Arg} = Union{map(x-> Combined{x, Arg}, atomic_functions)...} +function Plot{Func}(args::Tuple, plot_attributes::Dict) where {Func} + if !isempty(args) && first(args) isa Attributes + merge!(plot_attributes, attributes(first(args))) + return Plot{Func}(Base.tail(args), plot_attributes) + end + P = Plot{Func} + used_attrs = used_attributes(P, to_value.(args)...) + if used_attrs === () + args_converted = convert_arguments(P, map(to_value, args)...) + else + kw = [Pair(k, to_value(v)) for (k, v) in plot_attributes if k in used_attrs] + args_converted = convert_arguments(P, map(to_value, args)...; kw...) + end + PNew, converted = apply_convert!(P, Attributes(), args_converted) + + obs_args = Any[convert(Observable, x) for x in args] -function (PT::Type{<: Combined})(parent, transformation, attributes, input_args, converted) - PT(parent, transformation, attributes, input_args, converted, AbstractPlot[]) + ArgTyp = MakieCore.argtypes(converted) + converted_obs = map(Observable, converted) + FinalPlotFunc = plotfunc(plottype(PNew, converted...)) + return Plot{FinalPlotFunc,ArgTyp}(plot_attributes, obs_args, converted_obs) end """ @@ -149,107 +173,32 @@ Usage: end ``` """ -used_attributes(PlotType, args...) = () +used_attributes(::Type{<:Plot}, args...) = used_attributes(args...) +used_attributes(args...) = () -""" -apply for return type - (args...,) -""" -function apply_convert!(P, attributes::Attributes, x::Tuple) - return (plottype(P, x...), x) -end +## generic definitions +# Chose the more specific plot type from arguments or input type +# Note the plottype(Scatter, Plot{plot}) will return Scatter +# And plottype(args...) falls back to Plot{plot} +plottype(P::Type{<: Plot{T}}, argvalues...) where T = plottype(P, plottype(argvalues...)) +plottype(P::Type{<:Plot{T}}) where {T} = P +plottype(P1::Type{<:Plot{T1}}, ::Type{<:Plot{T2}}) where {T1, T2} = P1 +plottype(::Type{Plot{plot}}, ::Type{Plot{plot}}) = Plot{plot} """ -apply for return type PlotSpec -""" -function apply_convert!(P, attributes::Attributes, x::PlotSpec{S}) where S - args, kwargs = x.args, x.kwargs - # Note that kw_args in the plot spec that are not part of the target plot type - # will end in the "global plot" kw_args (rest) - for (k, v) in pairs(kwargs) - attributes[k] = v - end - return (plottype(S, P), args) -end - -function seperate_tuple(args::Observable{<: NTuple{N, Any}}) where N - ntuple(N) do i - lift(args) do x - if i <= length(x) - x[i] - else - error("You changed the number of arguments. This isn't allowed!") - end - end - end -end - -function (PlotType::Type{<: AbstractPlot{Typ}})(scene::SceneLike, attributes::Attributes, args) where Typ - input = convert.(Observable, args) - aconvert(args...) = convert_arguments(PlotType, args...) - argnodes = lift(aconvert, input...) - plot = PlotType(scene, attributes, input, argnodes) - # Manually register obsfuncs, since we can't do lift(aconvert, plot, input...) - for arg in input - push!(plot.deregister_callbacks, Observables.ObserverFunction(aconvert, arg, false)) - end - return plot -end - -function plot(scene::Scene, plot::AbstractPlot) - # plot object contains local theme (default values), and user given values (from constructor) - # fill_theme now goes through all values that are missing from the user, and looks if the scene - # contains any theming values for them (via e.g. css rules). If nothing founds, the values will - # be taken from local theme! This will connect any values in the scene's theme - # with the plot values and track those connection, so that we can separate them - # when doing delete!(scene, plot)! - complete_theme!(scene, plot) - # we just return the plot... whoever calls plot (our pipeline usually) - # will need to push!(scene, plot) etc! - return plot -end + plottype(P1::Type{<: Plot{T1}}, P2::Type{<: Plot{T2}}) -function (PlotType::Type{<: AbstractPlot{Typ}})(scene::SceneLike, attributes::Attributes, input, args) where Typ - # The argument type of the final plot object is the assumened to stay constant after - # argument conversion. This might not always hold, but it simplifies - # things quite a bit - ArgTyp = typeof(to_value(args)) - # construct the fully qualified plot type, from the possible incomplete (abstract) - # PlotType - - FinalType = Combined{Typ, ArgTyp} - plot_attributes = merged_get!( - ()-> default_theme(scene, FinalType), - plotsym(FinalType), scene, attributes - ) - - # Transformation is a field of the plot type, but can be given as an attribute - trans = get(plot_attributes, :transformation, automatic) - transval = to_value(trans) - transformation = if transval === automatic - Transformation(scene) - elseif isa(transval, Transformation) - transval - else - t = Transformation(scene) - transform!(t, transval) - t - end - replace_automatic!(plot_attributes, :model) do - transformation.model - end - # create the plot, with the full attributes, the input signals, and the final signals. - plot_obj = FinalType(scene, transformation, plot_attributes, input, seperate_tuple(args)) - calculated_attributes!(plot_obj) - plot_obj +Chooses the more concrete plot type +```julia +function convert_arguments(P::PlotFunc, args...) + ptype = plottype(P, Lines) + ... end +``` +""" +plottype(::Type{Plot{plot}}, P::Type{<:Plot{T}}) where {T} = P +plottype(P::Type{<:Plot{T}}, ::Type{Plot{plot}}) where {T} = P -## generic definitions -# If the Combined has no plot func, calculate them -plottype(::Type{<: Combined{Any}}, argvalues...) = plottype(argvalues...) -plottype(::Type{Any}, argvalues...) = plottype(argvalues...) -# If it has something more concrete than Any, use it directly -plottype(P::Type{<: Combined{T}}, argvalues...) where T = P ## specialized definitions for types plottype(::AbstractVector, ::AbstractVector, ::AbstractVector) = Scatter @@ -268,178 +217,65 @@ plottype(::GeometryBasics.AbstractPolygon) = Poly plottype(::AbstractVector{<:GeometryBasics.AbstractPolygon}) = Poly plottype(::MultiPolygon) = Lines -""" - plottype(P1::Type{<: Combined{T1}}, P2::Type{<: Combined{T2}}) -Chooses the more concrete plot type -```julia -function convert_arguments(P::PlotFunc, args...) - ptype = plottype(P, Lines) - ... -end -``` -""" -plottype(P1::Type{<: Combined{Any}}, P2::Type{<: Combined{T}}) where T = P2 -plottype(P1::Type{<: Combined{T}}, P2::Type{<: Combined}) where T = P1 # all the plotting functions that get a plot type -const PlotFunc = Union{Type{Any}, Type{<: AbstractPlot}} +const PlotFunc = Union{Type{Any},Type{<:AbstractPlot}} - -###################################################################### -# In this section, the plotting functions have P as the first argument -# These are called from type recipes - -function plot!(P::PlotFunc, scene::SceneLike, args...; kw_attributes...) - attributes = Attributes(kw_attributes) - plot!(scene, P, attributes, args...) -end - -# with positional attributes - -function plot!(P::PlotFunc, scene::SceneLike, attrs::Attributes, args...; kw_attributes...) - attributes = merge!(Attributes(kw_attributes), attrs) - plot!(scene, P, attributes, args...) +function plot!(::Plot{F}) where {F} + if !(F in atomic_functions) + error("No recipe for $(F)") + end end -###################################################################### -# plots to scene +function connect_plot!(parent::SceneLike, plot::Plot{F}) where {F} + plot.parent = parent -""" -Main plotting signatures that plot/plot! route to if no Plot Type is given -""" -function plot!(scene::Union{Combined, SceneLike}, P::PlotFunc, attributes::Attributes, args...; kw_attributes...) - attributes = merge!(Attributes(kw_attributes), attributes) - argvalues = to_value.(args) - PreType = plottype(P, argvalues...) - # plottype will lose the argument types, so we just extract the plot func - # type and recreate the type with the argument type - PreType = Combined{plotfunc(PreType), typeof(argvalues)} - convert_keys = intersect(used_attributes(PreType, argvalues...), keys(attributes)) - kw_signal = if isempty(convert_keys) # lift(f) isn't supported so we need to catch the empty case - Observable(()) + apply_theme!(parent_scene(parent), plot) + t_user = to_value(get(attributes(plot), :transformation, automatic)) + if t_user isa Transformation + plot.transformation = t_user else - # Remove used attributes from `attributes` and collect them in a `Tuple` to pass them more easily - lift((args...) -> Pair.(convert_keys, args), scene, pop!.(attributes, convert_keys)...) - end - # call convert_arguments for a first time to get things started - converted = convert_arguments(PreType, argvalues...; kw_signal[]...) - # convert_arguments can return different things depending on the recipe type - # apply_conversion deals with that! - - FinalType, argsconverted = apply_convert!(PreType, attributes, converted) - converted_node = Observable(argsconverted) - input_nodes = convert.(Observable, args) - obs_funcs = onany(kw_signal, input_nodes...) do kwargs, args... - # do the argument conversion inside a lift - result = convert_arguments(FinalType, args...; kwargs...) - finaltype, argsconverted_ = apply_convert!(FinalType, attributes, result) # avoid a Core.Box (https://docs.julialang.org/en/v1/manual/performance-tips/#man-performance-captured) - if finaltype != FinalType - error("Plot type changed from $FinalType to $finaltype after conversion. - Changing the plot type based on values in convert_arguments is not allowed" - ) + if t_user isa Automatic + plot.transformation = Transformation() + else + t = Transformation() + transform!(t, t_user) + plot.transformation = t end - converted_node[] = argsconverted_ - end - plot_object = plot!(scene, FinalType, attributes, input_nodes, converted_node) - # bind observable clean up to plot object: - append!(plot_object.deregister_callbacks, obs_funcs) - return plot_object -end - -plot!(p::Combined) = _plot!(p) - -_plot!(p::Atomic{T}) where T = p - -function _plot!(p::Combined{fn, T}) where {fn, T} - throw(PlotMethodError(fn, T)) -end - -struct PlotMethodError <: Exception - fn - T -end - -function Base.showerror(io::IO, err::PlotMethodError) - fn = err.fn - T = err.T - args = (T.parameters...,) - typed_args = join(string.("::", args), ", ") - - print(io, "PlotMethodError: no ") - printstyled(io, fn == Any ? "plot" : fn; color=:cyan) - print(io, " method for arguments ") - printstyled(io, "($typed_args)"; color=:cyan) - print(io, ". To support these arguments, define\n ") - printstyled(io, "plot!(::$(Combined{fn,S} where {S<:T}))"; color=:cyan) - print(io, "\nAvailable methods are:\n") - for m in methods(plot!) - if m.sig <: Tuple{typeof(plot!), Combined{fn}} - println(io, " ", m) + if is_space_compatible(plot, parent) + obsfunc = connect!(transformation(parent), transformation(plot)) + append!(plot.deregister_callbacks, obsfunc) end end + plot.model = transformationmatrix(plot) + convert_arguments!(plot) + calculated_attributes!(Plot{F}, plot) + default_shading!(plot, parent_scene(parent)) + plot!(plot) + return plot end -function show_attributes(attributes) - for (k, v) in attributes - println(" ", k, ": ", v[] == nothing ? "nothing" : v[]) - end +function plot!(scene::SceneLike, plot::Plot) + connect_plot!(scene, plot) + push!(scene, plot) + return plot end -""" - extract_scene_attributes!(attributes) - -removes all scene attributes from `attributes` and returns them in a new -Attribute dict. -""" -function extract_scene_attributes!(attributes) - scene_attributes = ( - :backgroundcolor, - :resolution, - :show_axis, - :show_legend, - :scale_plot, - :center, - :axis, - :axis2d, - :axis3d, - :legend, - :camera, - :limits, - :padding, - :raw, - :SSAO - ) - result = Attributes() - for k in scene_attributes - haskey(attributes, k) && (result[k] = pop!(attributes, k)) +function apply_theme!(scene::Scene, plot::P) where {P<: Plot} + raw_attr = attributes(plot.attributes) + plot_theme = default_theme(scene, P) + plot_sym = plotsym(P) + if haskey(theme(scene), plot_sym) + merge_without_obs_reverse!(plot_theme, theme(scene, plot_sym)) end - return result -end -function plot!(scene::SceneLike, P::PlotFunc, attributes::Attributes, input::NTuple{N, Observable}, args::Observable) where {N} - # create "empty" plot type - empty meaning containing no plots, just attributes + arguments - scene_attributes = extract_scene_attributes!(attributes) - if haskey(attributes, :textsize) - throw(ArgumentError("The attribute `textsize` has been renamed to `fontsize` in Makie v0.19. Please change all occurrences of `textsize` to `fontsize` or revert back to an earlier version.")) - end - plot_object = P(scene, attributes, input, args) - # transfer the merged attributes from theme and user defined to the scene - for (k, v) in scene_attributes - error("setting $k for scene via plot attribute not supported anymore") + for (k, v) in plot.kw + if v isa NamedTuple + raw_attr[k] = Attributes(v) + else + raw_attr[k] = convert(Observable{Any}, v) + end end - # call user defined recipe overload to fill the plot type - plot!(plot_object) - push!(scene, plot_object) - return plot_object -end - -function plot!(scene::Combined, P::PlotFunc, attributes::Attributes, input::NTuple{N,Observable}, args::Observable) where {N} - # create "empty" plot type - empty meaning containing no plots, just attributes + arguments - - plot_object = P(scene, attributes, input, args) - # call user defined recipe overload to fill the plot type - plot!(plot_object) - push!(scene.plots, plot_object) - plot_object + return merge!(plot.attributes, plot_theme) end diff --git a/src/layouting/boundingbox.jl b/src/layouting/boundingbox.jl index 18757dcb7a5..0a9226d16b1 100644 --- a/src/layouting/boundingbox.jl +++ b/src/layouting/boundingbox.jl @@ -48,14 +48,11 @@ end _inkboundingbox(ext::GlyphExtent) = ext.ink_bounding_box -function boundingbox(glyphcollection::GlyphCollection, position::Point3f, rotation::Quaternion) - return boundingbox(glyphcollection, rotation) + position -end +unchecked_boundingbox(glyphcollection::GlyphCollection, position::Point3f, rotation::Quaternion) = + unchecked_boundingbox(glyphcollection, rotation) + position -function boundingbox(glyphcollection::GlyphCollection, rotation::Quaternion) - if isempty(glyphcollection.glyphs) - return Rect3f(Point3f(0), Vec3f(0)) - end +function unchecked_boundingbox(glyphcollection::GlyphCollection, rotation::Quaternion) + isempty(glyphcollection.glyphs) && return Rect3f(Point3f(0), Vec3f(0)) glyphorigins = glyphcollection.origins glyphbbs = gl_bboxes(glyphcollection) @@ -69,25 +66,27 @@ function boundingbox(glyphcollection::GlyphCollection, rotation::Quaternion) bb = union(bb, charbb) end end - !isfinite_rect(bb) && error("Invalid text boundingbox") return bb end -function boundingbox(layouts::AbstractArray{<:GlyphCollection}, positions, rotations) - if isempty(layouts) - return Rect3f((0, 0, 0), (0, 0, 0)) - else - bb = Rect3f() - broadcast_foreach(layouts, positions, rotations) do layout, pos, rot - if !isfinite_rect(bb) - bb = boundingbox(layout, pos, rot) - else - bb = union(bb, boundingbox(layout, pos, rot)) - end +function unchecked_boundingbox(layouts::AbstractArray{<:GlyphCollection}, positions, rotations) + isempty(layouts) && return Rect3f((0, 0, 0), (0, 0, 0)) + + bb = Rect3f() + broadcast_foreach(layouts, positions, rotations) do layout, pos, rot + if !isfinite_rect(bb) + bb = boundingbox(layout, pos, rot) + else + bb = union(bb, boundingbox(layout, pos, rot)) end - !isfinite_rect(bb) && error("Invalid text boundingbox") - return bb end + return bb +end + +function boundingbox(x::Union{GlyphCollection,AbstractArray{<:GlyphCollection}}, args...) + bb = unchecked_boundingbox(x, args...) + isfinite_rect(bb) || error("Invalid text boundingbox") + bb end function boundingbox(x::Text{<:Tuple{<:GlyphCollection}}) diff --git a/src/layouting/data_limits.jl b/src/layouting/data_limits.jl index df81ae151dc..92d4e94a38b 100644 --- a/src/layouting/data_limits.jl +++ b/src/layouting/data_limits.jl @@ -40,20 +40,21 @@ function point_iterator(plot::Union{Scatter, MeshScatter, Lines, LineSegments}) end # TODO? -function point_iterator(text::Text{<: Tuple{<: Union{GlyphCollection, AbstractVector{GlyphCollection}}}}) +function data_limits(text::Text{<: Tuple{<: Union{GlyphCollection, AbstractVector{GlyphCollection}}}}) if is_data_space(text.markerspace[]) - return decompose(Point, boundingbox(text)) + return boundingbox(text) else if text.position[] isa VecTypes - return [to_ndim(Point3f, text.position[], 0.0)] + return Rect3f(text.position[]) else - return convert_arguments(PointBased(), text.position[])[1] + # TODO: is this branch necessary? + return Rect3f(convert_arguments(PointBased(), text.position[])[1]) end end end -function point_iterator(text::Text) - return point_iterator(text.plots[1]) +function data_limits(text::Text) + return data_limits(text.plots[1]) end point_iterator(mesh::GeometryBasics.Mesh) = decompose(Point, mesh) @@ -73,8 +74,6 @@ function point_iterator(list::AbstractVector) end end -point_iterator(plot::Combined) = point_iterator(plot.plots) - point_iterator(plot::Mesh) = point_iterator(plot.mesh[]) function br_getindex(vector::AbstractVector, idx::CartesianIndex, dim::Int) @@ -107,7 +106,7 @@ end foreach_plot(f, s::Figure) = foreach_plot(f, s.scene) foreach_plot(f, s::FigureAxisPlot) = foreach_plot(f, s.figure) foreach_plot(f, list::AbstractVector) = foreach(f, list) -function foreach_plot(f, plot::Combined) +function foreach_plot(f, plot::Plot) if isempty(plot.plots) f(plot) else @@ -167,11 +166,23 @@ function update_boundingbox!(bb_ref, bb::Rect) return end +# Default data_limits function data_limits(plot::AbstractPlot) - limits_from_transformed_points(iterate_transformed(plot)) + # Assume primitive plot + if isempty(plot.plots) + return limits_from_transformed_points(iterate_transformed(plot)) + end + + # Assume Plot Plot + bb_ref = Base.RefValue(data_limits(plot.plots[1])) + for i in 2:length(plot.plots) + update_boundingbox!(bb_ref, data_limits(plot.plots[i])) + end + + return bb_ref[] end -function _update_rect(rect::Rect{N, T}, point::Point{N, T}) where {N, T} +function _update_rect(rect::Rect{N, T}, point::VecTypes{N, T}) where {N, T} mi = minimum(rect) ma = maximum(rect) mis_mas = map(mi, ma, point) do _mi, _ma, _p @@ -191,6 +202,22 @@ function limits_from_transformed_points(points_iterator) return bb end +# include bbox from scaled markers +function limits_from_transformed_points(positions, scales, rotations, element_bbox) + isempty(positions) && return Rect3f() + + first_scale = attr_broadcast_getindex(scales, 1) + first_rot = attr_broadcast_getindex(rotations, 1) + full_bbox = Ref(first_rot * (element_bbox * first_scale) + first(positions)) + for (i, pos) in enumerate(positions) + scale, rot = attr_broadcast_getindex(scales, i), attr_broadcast_getindex(rotations, i) + transformed_bbox = rot * (element_bbox * scale) + pos + update_boundingbox!(full_bbox, transformed_bbox) + end + + return full_bbox[] +end + function data_limits(scenelike, exclude=(p)-> false) bb_ref = Base.RefValue(Rect3f()) foreach_plot(scenelike) do plot @@ -222,3 +249,20 @@ function data_limits(plot::Image) maxi = Vec3f(last.(mini_maxi)..., 0) return Rect3f(mini, maxi .- mini) end + +function data_limits(plot::MeshScatter) + # TODO: avoid mesh generation here if possible + @get_attribute plot (marker, markersize, rotations) + marker_bb = Rect3f(marker) + positions = iterate_transformed(plot) + scales = markersize + # fast path for constant markersize + if scales isa VecTypes{3} && rotations isa Quaternion + bb = limits_from_transformed_points(positions) + marker_bb = rotations * (marker_bb * scales) + return Rect3f(minimum(bb) + minimum(marker_bb), widths(bb) + widths(marker_bb)) + else + # TODO: optimize const scale, var rot and var scale, const rot + return limits_from_transformed_points(positions, scales, rotations, marker_bb) + end +end diff --git a/src/layouting/layouting.jl b/src/layouting/layouting.jl index d71fe598d4e..4765bbc7cad 100644 --- a/src/layouting/layouting.jl +++ b/src/layouting/layouting.jl @@ -1,9 +1,9 @@ using FreeTypeAbstraction: hadvance, leftinkbound, inkwidth, get_extent, ascender, descender -one_attribute_per_char(attribute, string) = (attribute for char in string) +one_attribute_per_char(attribute, string) = [attribute for char in string] function one_attribute_per_char(font::NativeFont, string) - return (find_font_for_char(char, font) for char in string) + return [find_font_for_char(char, font) for char in string] end function attribute_per_char(string, attribute) @@ -179,14 +179,8 @@ function glyph_collection( else 0.5f0 end - elseif justification === :left - 0.0f0 - elseif justification === :right - 1.0f0 - elseif justification === :center - 0.5f0 else - Float32(justification) + halign2num(justification, "Invalid justification $justification. Valid values are <:Real, :left, :center and :right.") end xs_justified = map(xs, width_differences) do xsgroup, wd @@ -203,17 +197,7 @@ function glyph_collection( ys = cumsum([0.0; -lineheights[2:end]]) # compute x values after left/center/right alignment - halign = if halign isa Number - Float32(halign) - elseif halign === :left - 0.0f0 - elseif halign === :center - 0.5f0 - elseif halign === :right - 1.0f0 - else - error("Invalid halign $halign. Valid values are <:Number, :left, :center and :right.") - end + halign = halign2num(halign) xs_aligned = [xsgroup .- halign * maxwidth for xsgroup in xs_justified] # for y alignment, we need the largest ascender of the first line @@ -233,17 +217,7 @@ function glyph_collection( ys_aligned = if valign === :baseline ys .- first_line_ascender .+ overall_height .+ last_line_descender else - va = if valign isa Number - Float32(valign) - elseif valign === :top - 1f0 - elseif valign === :bottom - 0f0 - elseif valign === :center - 0.5f0 - else - error("Invalid valign $valign. Valid values are <:Number, :bottom, :baseline, :top, and :center.") - end + va = valign2num(valign, "Invalid valign $valign. Valid values are <:Number, :bottom, :baseline, :top, and :center.") ys .- first_line_ascender .+ (1 - va) .* overall_height end @@ -287,14 +261,6 @@ function padded_vcat(arrs::AbstractVector{T}, fillvalue) where T <: AbstractVect arr end -function alignment2num(x::Symbol) - (x === :center) && return 0.5f0 - (x in (:left, :bottom)) && return 0.0f0 - (x in (:right, :top)) && return 1.0f0 - return 0.0f0 # 0 default, or better to error? -end - - # Backend data _offset_to_vec(o::VecTypes) = to_ndim(Vec3f, o, 0) diff --git a/src/layouting/transformation.jl b/src/layouting/transformation.jl index 6ddc7cd53d0..bde0e8b3340 100644 --- a/src/layouting/transformation.jl +++ b/src/layouting/transformation.jl @@ -1,48 +1,20 @@ Base.parent(t::Transformation) = isassigned(t.parent) ? t.parent[] : nothing -function Transformation(transform_func=identity; - scale=Vec3f(1), - translation=Vec3f(0), - rotation=Quaternionf(0, 0, 0, 1)) - - scale_o = convert(Observable{Vec3f}, scale) - translation_o = convert(Observable{Vec3f}, translation) - rotation_o = convert(Observable{Quaternionf}, rotation) - model = map(transformationmatrix, translation_o, scale_o, rotation_o) - return Transformation( - translation_o, - scale_o, - rotation_o, - model, - convert(Observable{Any}, transform_func) - ) -end - -function Transformation(transformable::Transformable; - scale=Vec3f(1), - translation=Vec3f(0), - rotation=Quaternionf(0, 0, 0, 1)) - - scale_o = convert(Observable{Vec3f}, scale) - translation_o = convert(Observable{Vec3f}, translation) - rotation_o = convert(Observable{Quaternionf}, rotation) - parent_transform = transformation(transformable) - - pmodel = parent_transform.model - model = map(translation_o, scale_o, rotation_o, pmodel) do t, s, r, p - return p * transformationmatrix(t, s, r) +function Observables.connect!(parent::Transformation, child::Transformation; connect_func=true) + tfuncs = [] + obsfunc = on(parent.model; update=true) do m + return child.parent_model[] = m end - - trans = Transformation( - translation_o, - scale_o, - rotation_o, - model, - copy(parent_transform.transform_func) - ) - - trans.parent[] = parent_transform - return trans + push!(tfuncs, obsfunc) + if connect_func + t2 = on(parent.transform_func; update=true) do f + child.transform_func[] = f + return + end + push!(tfuncs, t2) + end + child.parent[] = parent + return tfuncs end function free(transformation::Transformation) @@ -68,7 +40,7 @@ end function translated(scene::Scene; kw_args...) tscene = Scene(scene, transformation = Transformation()) transform!(tscene; kw_args...) - tscene + tscene end function transform!( @@ -83,7 +55,7 @@ function transform!( end function transform!( - scene::Transformable, attributes::Union{Attributes, AbstractDict} + scene::Transformable, attributes::Union{Attributes, AbstractDict, NamedTuple} ) transform!(scene; attributes...) end @@ -210,13 +182,13 @@ transform_func_obs(x) = transformation(x).transform_func apply_transform_and_model(model, transfrom_func, pos, output_type = Point3f) -Applies the transform function and model matrix (i.e. transformations from +Applies the transform function and model matrix (i.e. transformations from `translate!`, `rotate!` and `scale!`) to the given input """ function apply_transform_and_model(plot::AbstractPlot, pos, output_type = Point3f) return apply_transform_and_model( - plot.model[], transform_func(plot), pos, - to_value(get(plot, :space, :data)), + plot.model[], transform_func(plot), pos, + to_value(get(plot, :space, :data)), output_type ) end @@ -338,7 +310,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) @@ -359,61 +330,135 @@ 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 - -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) +const pseudolog10 = ReversibleScale( + x -> sign(x) * log10(abs(x) + 1), + x -> sign(x) * (exp10(abs(x)) - 1); + limits=(0f0, 3f0), + name=:pseudolog10 +) + +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 + return ReversibleScale(forward, inverse; limits=(0.0f0, 3.0f0), name=:Symlog10) end 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) end +################################################################################ +### Polar Transformation +################################################################################ + +""" + Polar(theta_as_x = true, clip_r = true, theta_0::Float64 = 0.0, direction::Int = +1, r0::Float64 = 0) + +This struct defines a general polar-to-cartesian transformation, i.e. + +```math +(r, θ) -> ((r - r₀) ⋅ \\cos(direction ⋅ (θ + θ₀)), (r - r₀) ⋅ \\sin(direction \\cdot (θ + θ₀))) +``` + +where θ is assumed to be in radians. + +Controls: +- `theta_as_x = true` controls the order of incoming arguments. If true, a `Point2f` +is interpreted as `(θ, r)`, otherwise `(r, θ)`. +- `clip_r = true` controls whether negative radii are clipped. If true, `r < 0` +produces `NaN`, otherwise they simply enter in the formula above as is. Note that +the inversion only returns `r ≥ 0` +- `theta_0 = 0` offsets angles by the specified amount. +- `direction = +1` inverts the direction of θ. +- `r0 = 0` offsets radii by the specified amount. Not that this will affect the +shape of transformed objects. +""" +struct Polar + theta_as_x::Bool + clip_r::Bool + theta_0::Float64 + direction::Int + r0::Float64 + + function Polar(theta_0::Real = 0.0, direction::Int = +1, r0::Real = 0, theta_as_x::Bool = true, clip_r::Bool = true) + return new(theta_as_x, clip_r, theta_0, direction, r0) + end +end + +Base.broadcastable(x::Polar) = (x,) + +function apply_transform(trans::Polar, point::VecTypes{2, T}) where T <: Real + if trans.theta_as_x + θ, r = point + else + r, θ = point + end + r = r - trans.r0 + if trans.clip_r && (r < 0.0) + return Point2{T}(NaN) + end + θ = trans.direction * (θ + trans.theta_0) + y, x = r .* sincos(θ) + return Point2{T}(x, y) +end + +# Point2 may get expanded to Point3. In that case we leave z untransformed +function apply_transform(f::Polar, point::VecTypes{N2, T}) where {N2, T} + p_dim = to_ndim(Point2f, point, 0.0) + p_trans = apply_transform(f, p_dim) + if 2 < N2 + p_large = ntuple(i-> i <= 2 ? p_trans[i] : point[i], N2) + return Point{N2, Float32}(p_large) + else + return to_ndim(Point{N2, Float32}, p_trans, 0.0) + end +end + +function inverse_transform(trans::Polar) + 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 + + # this is a simplification which will only really work with non-rotated or # scaled scene transformations, but for 2D scenes this should work well enough. # and this way we can use the z-value as a means to shift the drawing order diff --git a/src/lighting.jl b/src/lighting.jl new file mode 100644 index 00000000000..c8808a84e94 --- /dev/null +++ b/src/lighting.jl @@ -0,0 +1,278 @@ +abstract type AbstractLight end + +# GLMakie interface + +# These need to match up with light shaders to differentiate light types +module LightType + const UNDEFINED = 0 + const Ambient = 1 + const PointLight = 2 + const DirectionalLight = 3 + const SpotLight = 4 + const RectLight = 5 +end + +# Each light should implement +light_type(::AbstractLight) = LightType.UNDEFINED +light_color(::AbstractLight) = RGBf(0, 0, 0) +# Other attributes need to be handled explicitly in backends + + +""" + AmbientLight(color) <: AbstractLight + +A simple ambient light that uniformly lights every object based on its light color. + +Availability: +- All backends with `shading = FastShading` or `MultiLightShading` +""" +struct AmbientLight <: AbstractLight + color::Observable{RGBf} +end + +light_type(::AmbientLight) = LightType.Ambient +light_color(l::AmbientLight) = l.color[] + + +""" + PointLight(color, position[, attenuation = Vec2f(0)]) + PointLight(color, position, range::Real) + +A point-like light source placed at the given `position` with the given light +`color`. + +Optionally an attenuation parameter can be used to reduce the brightness of the +light source with distance. The reduction is given by +`1 / (1 + attenuation[1] * distance + attenuation[2] * distance^2)`. +Alternatively you can pass a light `range` to generate matching default +attenuation parameters. Note that you may need to set the light intensity, i.e. +the light color to values greater than 1 to get satisfying results. + +Availability: +- GLMakie with `shading = MultiLightShading` +- RPRMakie +""" +struct PointLight <: AbstractLight + color::Observable{RGBf} + position::Observable{Vec3f} + attenuation::Observable{Vec2f} +end + +# no attenuation +function PointLight(color::Union{Colorant, Observable{<: Colorant}}, position::Union{VecTypes{3}, Observable{<: VecTypes{3}}}) + return PointLight(color, position, Vec2f(0)) +end +# automatic attenuation +function PointLight(color::Union{Colorant, Observable{<: Colorant}}, position::Union{VecTypes{3}, Observable{<: VecTypes{3}}}, range::Real) + return PointLight(color, position, default_attenuation(range)) +end + +@deprecate PointLight(position::Union{VecTypes{3}, Observable{<: VecTypes{3}}}, color::Union{Colorant, Observable{<: Colorant}}) PointLight(color, position) + +light_type(::PointLight) = LightType.PointLight +light_color(l::PointLight) = l.color[] + +# fit of values used on learnopengl/ogre3d +function default_attenuation(range::Real) + return Vec2f( + 4.690507869767646 * range ^ -1.009712247799057, + 82.4447791934059 * range ^ -2.0192061630628966 + ) +end + + +""" + DirectionalLight(color, direction[, camera_relative = false]) + +A light type which simulates a distant light source with parallel light rays +going in the given `direction`. + +Availability: +- All backends with `shading = FastShading` or `MultiLightShading` +""" +struct DirectionalLight <: AbstractLight + color::Observable{RGBf} + direction::Observable{Vec3f} + + # Usually a light source is placed in world space, i.e. unrelated to the + # camera. As a default however, we want to make sure that an object is + # always reasonably lit, which requires the light source to move with the + # camera. To keep this in sync in WGLMakie, the calculation needs to happen + # in javascript. This flag notives WGLMakie and other backends that this + # calculation needs to happen. + camera_relative::Bool + + DirectionalLight(col, dir, rel = false) = new(col, dir, rel) +end +light_type(::DirectionalLight) = LightType.DirectionalLight +light_color(l::DirectionalLight) = l.color[] + + +""" + SpotLight(color, position, direction, angles) + +Creates a spot light which illuminates objects in a light cone starting at +`position` pointing in `direction`. The opening angle is defined by an inner +and outer angle given in `angles`, between which the light intensity drops off. + +Availability: +- GLMakie with `shading = MultiLightShading` +- RPRMakie +""" +struct SpotLight <: AbstractLight + color::Observable{RGBf} + position::Observable{Vec3f} + direction::Observable{Vec3f} + angles::Observable{Vec2f} +end + +light_type(::SpotLight) = LightType.SpotLight +light_color(l::SpotLight) = l.color[] + + +""" + EnvironmentLight(intensity, image) + +An environment light that uses a spherical environment map to provide lighting. +See: https://en.wikipedia.org/wiki/Reflection_mapping + +Availability: +- RPRMakie +""" +struct EnvironmentLight <: AbstractLight + intensity::Observable{Float32} + image::Observable{Matrix{RGBf}} +end + +""" + RectLight(color, r::Rect2[, direction = -normal]) + RectLight(color, center::Point3f, b1::Vec3f, b2::Vec3f[, direction = -normal]) + +Creates a RectLight with a given color. The first constructor derives the light +from a `Rect2` extending in x and y direction. The second specifies the `center` +of the rect (or more accurately parallelogram) with `b1` and `b2` specifying the +width and height vectors (including scale). + +Note that RectLight implements `translate!`, `rotate!` and `scale!` to simplify +adjusting the light. + +Availability: +- GLMakie with `Shading = MultiLightShading` +""" +struct RectLight <: AbstractLight + color::Observable{RGBf} + position::Observable{Point3f} + u1::Observable{Vec3f} + u2::Observable{Vec3f} + direction::Observable{Vec3f} +end + +RectLight(color, pos, u1, u2) = RectLight(color, pos, u1, u2, -normalize(cross(u1, u2))) +function RectLight(color, r::Rect2) + mini = minimum(r); ws = widths(r) + position = Observable(to_ndim(Point3f, mini + 0.5 * ws, 0)) + u1 = Observable(Vec3f(ws[1], 0, 0)) + u2 = Observable(Vec3f(0, ws[2], 0)) + return RectLight(color, position, u1, u2, normalize(Vec3f(0,0,-1))) +end + +# Implement Transformable interface (more or less) to simplify working with +# RectLights + +function translate!(::Type{T}, l::RectLight, v) where T + offset = to_ndim(Vec3f, Float32.(v), 0) + if T === Accum + l.position[] = l.position[] + offset + elseif T === Absolute + l.position[] = offset + else + error("Unknown translation type: $T") + end +end +translate!(l::RectLight, v) = translate!(Absolute, l, v) + +function rotate!(l::RectLight, q...) + rot = convert_attribute(q, key"rotation"()) + l.u1[] = rot * l.u1[] + l.u2[] = rot * l.u2[] + l.direction[] = rot * l.direction[] +end + +function scale!(::Type{T}, l::RectLight, s) where T + scale = to_ndim(Vec2f, Float32.(s), 0) + if T === Accum + l.u1[] = scale[1] * l.u1[] + l.u2[] = scale[2] * l.u2[] + elseif T === Absolute + l.u1[] = scale[1] * normalize(l.u1[]) + l.u2[] = scale[2] * normalize(l.u2[]) + else + error("Unknown translation type: $T") + end +end +scale!(l::RectLight, x::Real, y::Real) = scale!(Accum, l, Vec2f(x, y)) +scale!(l::RectLight, xy::VecTypes) = scale!(Accum, l, xy) + + +light_type(::RectLight) = LightType.RectLight +light_color(l::RectLight) = l.color[] + + +################################################################################ + + +function get_one_light(lights, Typ) + indices = findall(x-> x isa Typ, lights) + isempty(indices) && return nothing + return lights[indices[1]] +end + +function default_shading!(plot, lights::Vector{<: AbstractLight}) + # if the plot does not have :shading we assume the plot doesn't support it + haskey(plot.attributes, :shading) || return + + # Bad type + shading = to_value(plot.attributes[:shading]) + if !(shading isa MakieCore.ShadingAlgorithm || shading === automatic) + prev = shading + if (shading isa Bool) && (shading == false) + shading = NoShading + else + shading = automatic + end + @warn "`shading = $prev` is not valid. Use `Makie.automatic`, `NoShading`, `FastShading` or `MultiLightShading`. Defaulting to `$shading`." + end + + # automatic conversion + if shading === automatic + ambient_count = 0 + dir_light_count = 0 + + for light in lights + if light isa AmbientLight + ambient_count += 1 + elseif light isa DirectionalLight + dir_light_count += 1 + elseif light isa EnvironmentLight + continue + else + plot.attributes[:shading] = MultiLightShading + return + end + if ambient_count > 1 || dir_light_count > 1 + plot.attributes[:shading] = MultiLightShading + return + end + end + + if dir_light_count + ambient_count == 0 + shading = NoShading + else + shading = FastShading + end + end + + plot.attributes[:shading] = shading + + return +end \ No newline at end of file diff --git a/src/makielayout/MakieLayout.jl b/src/makielayout/MakieLayout.jl index 7ef641f1435..1cbe57bc478 100644 --- a/src/makielayout/MakieLayout.jl +++ b/src/makielayout/MakieLayout.jl @@ -1,5 +1,4 @@ import Formatting -using Match import Animations using GridLayoutBase using GridLayoutBase: GridSubposition @@ -21,6 +20,7 @@ include("lineaxis.jl") include("interactions.jl") include("blocks/axis.jl") include("blocks/axis3d.jl") +include("blocks/polaraxis.jl") include("blocks/colorbar.jl") include("blocks/label.jl") include("blocks/slider.jl") @@ -36,6 +36,7 @@ include("blocks/textbox.jl") export Axis export Axis3 +export PolarAxis export Slider export SliderGrid export IntervalSlider @@ -51,7 +52,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! @@ -60,13 +61,12 @@ export labelslider!, labelslidergrid! export addmouseevents! export interactions, register_interaction!, deregister_interaction!, activate_interaction!, deactivate_interaction! export MouseEventTypes, MouseEvent, ScrollEvent, KeysEvent -# export hlines!, vlines!, abline!, hspan!, vspan! +export hlines!, vlines!, abline!, hspan!, vspan! export Cycle export Cycled # from GridLayoutBase export GridLayout, GridPosition, GridSubposition -export GridLayoutSpec export BBox export LayoutObservables export Inside, Outside, Mixed @@ -91,5 +91,3 @@ export grid!, hgrid!, vgrid! export swap! export ncols, nrows export contents, content - -Base.@deprecate_binding MakieLayout Makie true "The module `MakieLayout` has been removed and integrated into Makie, so simply replace all usage of `MakieLayout` with `Makie`." diff --git a/src/makielayout/blocks.jl b/src/makielayout/blocks.jl index 855c28627ae..c6bf0bdb73f 100644 --- a/src/makielayout/blocks.jl +++ b/src/makielayout/blocks.jl @@ -1,4 +1,4 @@ -abstract type Block end + function is_attribute end function default_attribute_values end @@ -6,13 +6,14 @@ function attribute_default_expressions end function _attribute_docs end function has_forwarded_layout end - -macro Block(name::Symbol, body::Expr = Expr(:block)) +macro Block(_name::Union{Expr, Symbol}, body::Expr = Expr(:block)) body.head === :block || error("A Block needs to be defined within a `begin end` block") + type_expr = _name isa Expr ? _name : :($_name <: Makie.Block) + name = _name isa Symbol ? _name : _name.args[1] structdef = quote - mutable struct $name <: Makie.Block + mutable struct $(type_expr) parent::Union{Figure, Scene, Nothing} layoutobservables::Makie.LayoutObservables{GridLayout} blockscene::Scene @@ -64,7 +65,7 @@ macro Block(name::Symbol, body::Expr = Expr(:block)) function Makie.default_attribute_values(::Type{$(name)}, scene::Union{Scene, Nothing}) sceneattrs = scene === nothing ? Attributes() : theme(scene) - curdeftheme = fast_deepcopy($(Makie).CURRENT_DEFAULT_THEME) + curdeftheme = Makie.fast_deepcopy($(Makie).CURRENT_DEFAULT_THEME) $(make_attr_dict_expr(attrs, :sceneattrs, :curdeftheme)) end @@ -255,6 +256,7 @@ end can_be_current_axis(x) = false +get_top_parent(gp::GridLayout) = GridLayoutBase.top_parent(gp) get_top_parent(gp::GridPosition) = GridLayoutBase.top_parent(gp.layout) get_top_parent(gp::GridSubposition) = get_top_parent(gp.parent) @@ -269,50 +271,62 @@ function _block(T::Type{<:Block}, b end -function _block(T::Type{<:Block}, fig_or_scene::Union{Figure, Scene}, - args...; bbox = nothing, kwargs...) - - # first sort out all user kwargs that correspond to block attributes - kwdict = Dict(kwargs) - - if haskey(kwdict, :textsize) - throw(ArgumentError("The attribute `textsize` has been renamed to `fontsize` in Makie v0.19. Please change all occurrences of `textsize` to `fontsize` or revert back to an earlier version.")) - end - attribute_kwargs = Dict{Symbol, Any}() - for (key, value) in kwdict - if is_attribute(T, key) - attribute_kwargs[key] = pop!(kwdict, key) - end - end - # the non-attribute kwargs will be passed to the block later - non_attribute_kwargs = kwdict +function _block(T::Type{<:Block}, fig_or_scene::Union{Figure, Scene}, args...; bbox = nothing, kwargs...) + return _block(T, fig_or_scene, Any[args...], Dict{Symbol,Any}(kwargs), bbox) +end - topscene = get_topscene(fig_or_scene) - # retrieve the default attributes for this block given the scene theme - # and also the `Block = (...` style attributes from scene and global theme - default_attrs = default_attribute_values(T, topscene) - typekey_scene_attrs = get(theme(topscene), nameof(T), Attributes())::Attributes - typekey_attrs = theme(nameof(T); default=Attributes())::Attributes +function block_defaults(blockname::Symbol, attribute_kwargs::Dict, scene::Union{Nothing, Scene}) + return block_defaults(getfield(Makie, blockname), attribute_kwargs, scene) +end +function block_defaults(::Type{B}, attribute_kwargs::Dict, scene::Union{Nothing, Scene}) where {B <: Block} + default_attrs = default_attribute_values(B, scene) + blockname = nameof(B) + typekey_scene_attrs = get(theme(scene), blockname, Attributes()) + typekey_attrs = theme(blockname; default=Attributes())::Attributes + attributes = Dict{Symbol,Any}() # make a final attribute dictionary using different priorities # for the different themes - attributes = Dict{Symbol, Any}() for (key, val) in default_attrs # give kwargs priority if haskey(attribute_kwargs, key) attributes[key] = attribute_kwargs[key] - # otherwise scene theme + # otherwise scene theme elseif haskey(typekey_scene_attrs, key) attributes[key] = typekey_scene_attrs[key] - # otherwise global theme + # otherwise global theme elseif haskey(typekey_attrs, key) attributes[key] = typekey_attrs[key] - # otherwise its the value from the type default theme + # otherwise its the value from the type default theme else attributes[key] = val end end + return attributes +end + +function _block(T::Type{<:Block}, fig_or_scene::Union{Figure,Scene}, args, kwdict::Dict, bbox; kwdict_complete=false) + + # first sort out all user kwargs that correspond to block attributes + check_textsize_deprecation(kwdict) + + attribute_kwargs = Dict{Symbol, Any}() + for (key, value) in kwdict + if is_attribute(T, key) + attribute_kwargs[key] = pop!(kwdict, key) + end + end + # the non-attribute kwargs will be passed to the block later + non_attribute_kwargs = kwdict + topscene = get_topscene(fig_or_scene) + # retrieve the default attributes for this block given the scene theme + # and also the `Block = (...` style attributes from scene and global theme + if kwdict_complete + attributes = attribute_kwargs + else + attributes = block_defaults(T, attribute_kwargs, topscene) + end # create basic layout observables and connect attribute observables further down # after creating the block with its observable fields @@ -404,6 +418,7 @@ end """ Get the scene which blocks need from their parent to plot stuff into """ +get_topscene(f::Union{GridPosition, GridSubposition}) = get_topscene(get_top_parent(f)) get_topscene(f::Figure) = f.scene function get_topscene(s::Scene) if !(Makie.cameracontrols(s) isa Makie.PixelCamera) @@ -465,7 +480,7 @@ free(::Block) = nothing function Base.delete!(block::Block) free(block) block.parent === nothing && return - # detach plots, cameras, transformations, px_area + # detach plots, cameras, transformations, viewport empty!(block.blockscene) gc = GridLayoutBase.gridcontent(block) @@ -509,22 +524,22 @@ end # if a non-observable is passed, its value is converted and placed into an observable of # the correct type which is then used as the block field -function init_observable!(@nospecialize(x), key, @nospecialize(OT), @nospecialize(value)) +function init_observable!(@nospecialize(block), key::Symbol, @nospecialize(OT), @nospecialize(value)) o = convert_for_attribute(observable_type(OT), value) - setfield!(x, key, OT(o)) - return x + setfield!(block, key, OT(o)) + return block end # if an observable is passed, a converted type is lifted off of it, so it is # not used directly as a block field -function init_observable!(@nospecialize(x), key, @nospecialize(OT), @nospecialize(value::Observable)) +function init_observable!(@nospecialize(block), key::Symbol, @nospecialize(OT), @nospecialize(value::Observable)) obstype = observable_type(OT) o = Observable{obstype}() - map!(o, value) do v + map!(block.blockscene, o, value) do v convert_for_attribute(obstype, v) end - setfield!(x, key, o) - return x + setfield!(block, key, o) + return block end observable_type(x::Type{Observable{T}}) where T = T diff --git a/src/makielayout/blocks/axis.jl b/src/makielayout/blocks/axis.jl index 56d6c66b658..e43c2d6f74c 100644 --- a/src/makielayout/blocks/axis.jl +++ b/src/makielayout/blocks/axis.jl @@ -42,8 +42,8 @@ function register_events!(ax, scene) on(scene, evs.scroll) do s if is_mouseinside(scene) - scrollevents[] = ScrollEvent(s[1], s[2]) - return Consume(true) + result = setindex!(scrollevents, ScrollEvent(s[1], s[2])) + return Consume(result) end return Consume(false) end @@ -158,17 +158,11 @@ function compute_protrusions(title, titlesize, titlegap, titlevisible, spinewidt end function initialize_block!(ax::Axis; palette = nothing) - blockscene = ax.blockscene elements = Dict{Symbol, Any}() ax.elements = elements - if palette === nothing - palette = fast_deepcopy(haskey(blockscene.theme, :palette) ? blockscene.theme[:palette] : Makie.DEFAULT_PALETTES) - end - ax.palette = palette isa Attributes ? palette : Attributes(palette) - # initialize either with user limits, or pick defaults based on scales # so that we don't immediately error targetlimits = Observable{Rect2f}(defaultlimits(ax.limits[], ax.xscale[], ax.yscale[])) @@ -176,14 +170,6 @@ function initialize_block!(ax::Axis; palette = nothing) setfield!(ax, :targetlimits, targetlimits) setfield!(ax, :finallimits, finallimits) - ax.cycler = Cycler() - - # the first thing to do when setting a new scale is - # resetting the limits because simply through expanding they might be invalid for log - onany(blockscene, ax.xscale, ax.yscale) do _, _ - reset_limits!(ax) - end - on(blockscene, targetlimits) do lims # this should validate the targetlimits before anything else happens with them # so there should be nothing before this lifting `targetlimits` @@ -196,12 +182,18 @@ function initialize_block!(ax::Axis; palette = nothing) scenearea = sceneareanode!(ax.layoutobservables.computedbbox, finallimits, ax.aspect) - scene = Scene(blockscene, px_area=scenearea) + scene = Scene(blockscene, viewport=scenearea) ax.scene = scene + if !isnothing(palette) + # Backwards compatibility for when palette was part of axis! + palette_attr = palette isa Attributes ? palette : Attributes(palette) + ax.scene.theme.palette = palette_attr + end + # TODO: replace with mesh, however, CairoMakie needs a poly path for this signature # so it doesn't rasterize the scene - background = poly!(blockscene, scenearea; color=ax.backgroundcolor, inspectable=false, shading=false, strokecolor=:transparent) + background = poly!(blockscene, scenearea; color=ax.backgroundcolor, inspectable=false, shading=NoShading, strokecolor=:transparent) translate!(background, 0, 0, -100) elements[:background] = background @@ -247,16 +239,26 @@ function initialize_block!(ax::Axis; palette = nothing) translate!(yminorgridlines, 0, 0, -10) elements[:yminorgridlines] = yminorgridlines + # When the transform function (xscale, yscale) of a plot changes we + # 1. communicate this change to plots (barplot needs this to make bars + # compatible with the new transform function/scale) onany(blockscene, ax.xscale, ax.yscale) do xsc, ysc scene.transformation.transform_func[] = (xsc, ysc) return end + # 2. Update the limits of the plot + onany(blockscene, scene.transformation.transform_func, priority = -1) do _ + reset_limits!(ax) + end + notify(ax.xscale) - onany(update_axis_camera, camera(scene), scene.transformation.transform_func, finallimits, ax.xreversed, ax.yreversed) + # 3. Update the view onto the plot (camera matrices) + onany(update_axis_camera, blockscene, camera(scene), scene.transformation.transform_func, finallimits, + ax.xreversed, ax.yreversed; priority=-2) - xaxis_endpoints = lift(blockscene, ax.xaxisposition, scene.px_area; + xaxis_endpoints = lift(blockscene, ax.xaxisposition, scene.viewport; ignore_equal_values=true) do xaxisposition, area if xaxisposition === :bottom return bottomline(Rect2f(area)) @@ -267,7 +269,7 @@ function initialize_block!(ax::Axis; palette = nothing) end end - yaxis_endpoints = lift(blockscene, ax.yaxisposition, scene.px_area; + yaxis_endpoints = lift(blockscene, ax.yaxisposition, scene.viewport; ignore_equal_values=true) do yaxisposition, area if yaxisposition === :left return leftline(Rect2f(area)) @@ -344,7 +346,7 @@ function initialize_block!(ax::Axis; palette = nothing) ax.yaxis = yaxis - xoppositelinepoints = lift(blockscene, scene.px_area, ax.spinewidth, ax.xaxisposition; + xoppositelinepoints = lift(blockscene, scene.viewport, ax.spinewidth, ax.xaxisposition; ignore_equal_values=true) do r, sw, xaxpos if xaxpos === :top y = bottom(r) @@ -359,7 +361,7 @@ function initialize_block!(ax::Axis; palette = nothing) end end - yoppositelinepoints = lift(blockscene, scene.px_area, ax.spinewidth, ax.yaxisposition; + yoppositelinepoints = lift(blockscene, scene.viewport, ax.spinewidth, ax.yaxisposition; ignore_equal_values=true) do r, sw, yaxpos if yaxpos === :right x = left(r) @@ -375,22 +377,22 @@ function initialize_block!(ax::Axis; palette = nothing) end xticksmirrored = lift(mirror_ticks, blockscene, xaxis.tickpositions, ax.xticksize, ax.xtickalign, - Ref(scene.px_area), :x, ax.xaxisposition[]) + scene.viewport, :x, ax.xaxisposition[], ax.spinewidth) xticksmirrored_lines = linesegments!(blockscene, xticksmirrored, visible = @lift($(ax.xticksmirrored) && $(ax.xticksvisible)), linewidth = ax.xtickwidth, color = ax.xtickcolor) translate!(xticksmirrored_lines, 0, 0, 10) yticksmirrored = lift(mirror_ticks, blockscene, yaxis.tickpositions, ax.yticksize, ax.ytickalign, - Ref(scene.px_area), :y, ax.yaxisposition[]) + scene.viewport, :y, ax.yaxisposition[], ax.spinewidth) yticksmirrored_lines = linesegments!(blockscene, yticksmirrored, visible = @lift($(ax.yticksmirrored) && $(ax.yticksvisible)), linewidth = ax.ytickwidth, color = ax.ytickcolor) translate!(yticksmirrored_lines, 0, 0, 10) xminorticksmirrored = lift(mirror_ticks, blockscene, xaxis.minortickpositions, ax.xminorticksize, - ax.xminortickalign, Ref(scene.px_area), :x, ax.xaxisposition[]) + ax.xminortickalign, scene.viewport, :x, ax.xaxisposition[], ax.spinewidth) xminorticksmirrored_lines = linesegments!(blockscene, xminorticksmirrored, visible = @lift($(ax.xticksmirrored) && $(ax.xminorticksvisible)), linewidth = ax.xminortickwidth, color = ax.xminortickcolor) translate!(xminorticksmirrored_lines, 0, 0, 10) yminorticksmirrored = lift(mirror_ticks, blockscene, yaxis.minortickpositions, ax.yminorticksize, - ax.yminortickalign, Ref(scene.px_area), :y, ax.yaxisposition[]) + ax.yminortickalign, scene.viewport, :y, ax.yaxisposition[], ax.spinewidth) yminorticksmirrored_lines = linesegments!(blockscene, yminorticksmirrored, visible = @lift($(ax.yticksmirrored) && $(ax.yminorticksvisible)), linewidth = ax.yminortickwidth, color = ax.yminortickcolor) translate!(yminorticksmirrored_lines, 0, 0, 10) @@ -407,44 +409,37 @@ function initialize_block!(ax::Axis; palette = nothing) elements[:yoppositeline] = yoppositeline translate!(yoppositeline, 0, 0, 20) - onany(blockscene, xaxis.tickpositions, scene.px_area) do tickpos, area + onany(blockscene, xaxis.tickpositions, scene.viewport) do tickpos, area local pxheight::Float32 = height(area) local offset::Float32 = ax.xaxisposition[] === :bottom ? pxheight : -pxheight update_gridlines!(xgridnode, Point2f(0, offset), tickpos) end - onany(blockscene, yaxis.tickpositions, scene.px_area) do tickpos, area + onany(blockscene, yaxis.tickpositions, scene.viewport) do tickpos, area local pxwidth::Float32 = width(area) local offset::Float32 = ax.yaxisposition[] === :left ? pxwidth : -pxwidth update_gridlines!(ygridnode, Point2f(offset, 0), tickpos) end - onany(blockscene, xaxis.minortickpositions, scene.px_area) do tickpos, area - local pxheight::Float32 = height(scene.px_area[]) + onany(blockscene, xaxis.minortickpositions, scene.viewport) do tickpos, area + local pxheight::Float32 = height(scene.viewport[]) local offset::Float32 = ax.xaxisposition[] === :bottom ? pxheight : -pxheight update_gridlines!(xminorgridnode, Point2f(0, offset), tickpos) end - onany(blockscene, yaxis.minortickpositions, scene.px_area) do tickpos, area - local pxwidth::Float32 = width(scene.px_area[]) + onany(blockscene, yaxis.minortickpositions, scene.viewport) do tickpos, area + local pxwidth::Float32 = width(scene.viewport[]) local offset::Float32 = ax.yaxisposition[] === :left ? pxwidth : -pxwidth update_gridlines!(yminorgridnode, Point2f(offset, 0), tickpos) end - subtitlepos = lift(blockscene, scene.px_area, ax.titlegap, ax.titlealign, ax.xaxisposition, + subtitlepos = lift(blockscene, scene.viewport, ax.titlegap, ax.titlealign, ax.xaxisposition, xaxis.protrusion; ignore_equal_values=true) do a, titlegap, align, xaxisposition, xaxisprotrusion - x = if align === :center - a.origin[1] + a.widths[1] / 2 - elseif align === :left - a.origin[1] - elseif align === :right - a.origin[1] + a.widths[1] - else - error("Title align $align not supported.") - end + align_factor = halign2num(align, "Horizontal title align $align not supported.") + x = a.origin[1] + align_factor * a.widths[1] yoffset = top(a) + titlegap + (xaxisposition === :top ? xaxisprotrusion : 0f0) @@ -467,7 +462,7 @@ function initialize_block!(ax::Axis; palette = nothing) markerspace = :data, inspectable = false) - titlepos = lift(calculate_title_position, blockscene, scene.px_area, ax.titlegap, ax.subtitlegap, + titlepos = lift(calculate_title_position, blockscene, scene.viewport, ax.titlegap, ax.subtitlegap, ax.titlealign, ax.xaxisposition, xaxis.protrusion, ax.subtitlelineheight, ax, subtitlet; ignore_equal_values=true) titlet = text!( @@ -499,7 +494,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 @@ -510,7 +505,7 @@ function initialize_block!(ax::Axis; palette = nothing) # compute limits that adhere to the limit aspect ratio whenever the targeted # limits or the scene size change, because both influence the displayed ratio - onany(blockscene, scene.px_area, targetlimits) do pxa, lims + onany(blockscene, scene.viewport, targetlimits) do pxa, lims adjustlimits!(ax) end @@ -526,8 +521,8 @@ function initialize_block!(ax::Axis; palette = nothing) return ax end -function mirror_ticks(tickpositions, ticksize, tickalign, px_area, side, axisposition) - a = px_area[][] +function mirror_ticks(tickpositions, ticksize, tickalign, viewport, side, axisposition, spinewidth) + a = viewport if side === :x opp = axisposition === :bottom ? top(a) : bottom(a) sign = axisposition === :bottom ? 1 : -1 @@ -537,15 +532,16 @@ function mirror_ticks(tickpositions, ticksize, tickalign, px_area, side, axispos end d = ticksize * sign points = Vector{Point2f}(undef, 2*length(tickpositions)) + spineoffset = sign * (0.5 * spinewidth) if side === :x for (i, (x, _)) in enumerate(tickpositions) - points[2i-1] = Point2f(x, opp - d * tickalign) - points[2i] = Point2f(x, opp + d - d * tickalign) + points[2i-1] = Point2f(x, opp - d * tickalign + spineoffset) + points[2i] = Point2f(x, opp + d - d * tickalign + spineoffset) end else for (i, (_, y)) in enumerate(tickpositions) - points[2i-1] = Point2f(opp - d * tickalign, y) - points[2i] = Point2f(opp + d - d * tickalign, y) + points[2i-1] = Point2f(opp - d * tickalign + spineoffset, y) + points[2i] = Point2f(opp + d - d * tickalign + spineoffset, y) end end return points @@ -655,7 +651,6 @@ end function convert_limit_attribute(lims::Tuple{Any, Any}) lims end -can_be_current_axis(ax::Axis) = true function validate_limits_for_scales(lims::Rect, xsc, ysc) mi = minimum(lims) @@ -679,61 +674,55 @@ attrsyms(cycle::Cycle) = [c[1] for c in cycle.cycle] function get_cycler_index!(c::Cycler, P::Type) if !haskey(c.counters, P) - c.counters[P] = 1 + return c.counters[P] = 1 else - c.counters[P] += 1 + return c.counters[P] += 1 end end -function get_cycle_for_plottype(allattrs, P)::Cycle - psym = MakieCore.plotsym(P) - - plottheme = Makie.default_theme(nothing, P) - - cycle_raw = if haskey(allattrs, :cycle) - allattrs.cycle[] - else - global_theme_cycle = theme(psym) - if !isnothing(global_theme_cycle) && haskey(global_theme_cycle, :cycle) - global_theme_cycle.cycle[] - else - haskey(plottheme, :cycle) ? plottheme.cycle[] : nothing - end - end - +function get_cycle_for_plottype(cycle_raw)::Cycle if isnothing(cycle_raw) - Cycle([]) + return Cycle([]) elseif cycle_raw isa Cycle - cycle_raw + return cycle_raw else - Cycle(cycle_raw) + return Cycle(cycle_raw) end end -function add_cycle_attributes!(allattrs, P, cycle::Cycle, cycler::Cycler, palette::Attributes) +function to_color(scene::Scene, attribute_name, cycled::Cycled) + palettes = to_value(scene.theme.palette) + attr_palette = to_value(palettes[attribute_name]) + index = cycled.i + return attr_palette[mod1(index, length(attr_palette))] +end + +function add_cycle_attributes!(@nospecialize(plot), cycle::Cycle, cycler::Cycler, palette::Attributes) # check if none of the cycled attributes of this plot # were passed manually, because we don't use the cycler # if any of the cycled attributes were specified manually - no_cycle_attribute_passed = !any(keys(allattrs)) do key + user_attributes = plot.kw + no_cycle_attribute_passed = !any(keys(user_attributes)) do key any(syms -> key in syms, attrsyms(cycle)) end # check if any attributes were passed as `Cycled` entries # because if there were any, these are looked up directly # in the cycler without advancing the counter etc. - manually_cycled_attributes = filter(keys(allattrs)) do key - to_value(allattrs[key]) isa Cycled + manually_cycled_attributes = filter(keys(user_attributes)) do key + return to_value(user_attributes[key]) isa Cycled end # if there are any manually cycled attributes, we don't do the normal # cycling but only look up exactly the passed attributes cycle_attrsyms = attrsyms(cycle) + if !isempty(manually_cycled_attributes) # an attribute given as Cycled needs to be present in the cycler, # otherwise there's no cycle in which to look up a value for k in manually_cycled_attributes if !any(x -> k in x, cycle_attrsyms) - error("Attribute `$k` was passed with an explicit `Cycled` value, but $k is not specified in the cycler for this plot type $P.") + error("Attribute `$k` was passed with an explicit `Cycled` value, but $k is not specified in the cycler for this plot type $(typeof(plot)).") end end @@ -741,10 +730,10 @@ function add_cycle_attributes!(allattrs, P, cycle::Cycle, cycler::Cycler, palett for sym in manually_cycled_attributes isym = findfirst(syms -> sym in syms, attrsyms(cycle)) - index = allattrs[sym][].i + index = plot[sym][].i # replace the Cycled values with values from the correct palettes # at the index inside the Cycled object - allattrs[sym] = if cycle.covary + plot[sym] = if cycle.covary palettes[isym][mod1(index, length(palettes[isym]))] else cis = CartesianIndices(Tuple(length(p) for p in palettes)) @@ -756,13 +745,13 @@ function add_cycle_attributes!(allattrs, P, cycle::Cycle, cycler::Cycler, palett end elseif no_cycle_attribute_passed - index = get_cycler_index!(cycler, P) + index = get_cycler_index!(cycler, typeof(plot)) palettes = [palette[sym][] for sym in palettesyms(cycle)] for (isym, syms) in enumerate(attrsyms(cycle)) for sym in syms - allattrs[sym] = if cycle.covary + plot[sym] = if cycle.covary palettes[isym][mod1(index, length(palettes[isym]))] else cis = CartesianIndices(Tuple(length(p) for p in palettes)) @@ -776,38 +765,10 @@ function add_cycle_attributes!(allattrs, P, cycle::Cycle, cycler::Cycler, palett end end -function Makie.plot!( - la::Axis, P::Makie.PlotFunc, - attributes::Makie.Attributes, args...; - kw_attributes...) - - allattrs = merge(attributes, Attributes(kw_attributes)) - - _disallow_keyword(:axis, allattrs) - _disallow_keyword(:figure, allattrs) - - cycle = get_cycle_for_plottype(allattrs, P) - add_cycle_attributes!(allattrs, P, cycle, la.cycler, la.palette) - - plot = Makie.plot!(la.scene, P, allattrs, args...) - - # some area-like plots basically always look better if they cover the whole plot area. - # adjust the limit margins in those cases automatically. - needs_tight_limits(plot) && tightlimits!(la) - - if is_open_or_any_parent(la.scene) - reset_limits!(la) - end - plot -end - is_open_or_any_parent(s::Scene) = isopen(s) || is_open_or_any_parent(s.parent) is_open_or_any_parent(::Nothing) = false -function Makie.plot!(P::Makie.PlotFunc, ax::Axis, args...; kw_attributes...) - attributes = Makie.Attributes(kw_attributes) - Makie.plot!(ax, P, attributes, args...) -end + needs_tight_limits(@nospecialize any) = false needs_tight_limits(::Union{Heatmap, Image}) = true @@ -816,6 +777,13 @@ function needs_tight_limits(c::Contourf) # otherwise here it could be in an arbitrary shape return c.levels[] isa Int end +function needs_tight_limits(p::Triplot) + return p.show_ghost_edges[] +end +function needs_tight_limits(p::Voronoiplot) + p = p.plots[1] isa Voronoiplot ? p.plots[1] : p + return !isempty(DelTri.get_unbounded_polygons(p[1][])) +end function expandbboxwithfractionalmargins(bb, margins) newwidths = bb.widths .* (1f0 .+ margins) @@ -931,14 +899,21 @@ function update_linked_limits!(block_limit_linking, xaxislinks, yaxislinks, tlim end """ + autolimits!() autolimits!(la::Axis) Reset manually specified limits of `la` to an automatically determined rectangle, that depends on the data limits of all plot objects in the axis, as well as the autolimit margins for x and y axis. +The argument `la` defaults to `current_axis()`. """ function autolimits!(ax::Axis) ax.limits[] = (nothing, nothing) return end +function autolimits!() + curr_ax = current_axis() + isnothing(curr_ax) && throw(ArgumentError("Attempted to call `autolimits!` on `current_axis()`, but `current_axis()` returned nothing.")) + autolimits!(curr_ax) +end function autolimits(ax::Axis, dim::Integer) # try getting x limits for the axis and then union them with linked axes @@ -989,7 +964,7 @@ end function adjustlimits!(la) asp = la.autolimitaspect[] target = la.targetlimits[] - area = la.scene.px_area[] + area = la.scene.viewport[] # in the simplest case, just update the final limits with the target limits if isnothing(asp) || width(area) == 0 || height(area) == 0 @@ -1104,7 +1079,8 @@ end hidexdecorations!(la::Axis; label = true, ticklabels = true, ticks = true, grid = true, minorgrid = true, minorticks = true) -Hide decorations of the x-axis: label, ticklabels, ticks and grid. +Hide decorations of the x-axis: label, ticklabels, ticks and grid. Keyword +arguments can be used to disable hiding of certain types of decorations. """ function hidexdecorations!(la::Axis; label = true, ticklabels = true, ticks = true, grid = true, minorgrid = true, minorticks = true) @@ -1132,7 +1108,8 @@ end hideydecorations!(la::Axis; label = true, ticklabels = true, ticks = true, grid = true, minorgrid = true, minorticks = true) -Hide decorations of the y-axis: label, ticklabels, ticks and grid. +Hide decorations of the y-axis: label, ticklabels, ticks and grid. Keyword +arguments can be used to disable hiding of certain types of decorations. """ function hideydecorations!(la::Axis; label = true, ticklabels = true, ticks = true, grid = true, minorgrid = true, minorticks = true) @@ -1157,9 +1134,13 @@ function hideydecorations!(la::Axis; label = true, ticklabels = true, ticks = tr end """ - hidedecorations!(la::Axis) + hidedecorations!(la::Axis; label = true, ticklabels = true, ticks = true, + grid = true, minorgrid = true, minorticks = true) Hide decorations of both x and y-axis: label, ticklabels, ticks and grid. +Keyword arguments can be used to disable hiding of certain types of decorations. + +See also [`hidexdecorations!`], [`hideydecorations!`], [`hidezdecorations!`] """ function hidedecorations!(la::Axis; label = true, ticklabels = true, ticks = true, grid = true, minorgrid = true, minorticks = true) @@ -1173,24 +1154,29 @@ end hidespines!(la::Axis, spines::Symbol... = (:l, :r, :b, :t)...) Hide all specified axis spines. Hides all spines by default, otherwise choose -with the symbols :l, :r, :b and :t. +which sides to hide with the symbols :l (left), :r (right), :b (bottom) and +:t (top). """ function hidespines!(la::Axis, spines::Symbol... = (:l, :r, :b, :t)...) for s in spines - @match s begin - :l => (la.leftspinevisible = false) - :r => (la.rightspinevisible = false) - :b => (la.bottomspinevisible = false) - :t => (la.topspinevisible = false) - x => error("Invalid spine identifier $x. Valid options are :l, :r, :b and :t.") + if s === :l + la.leftspinevisible = false + elseif s === :r + la.rightspinevisible = false + elseif s === :b + la.bottomspinevisible = false + elseif s === :t + la.topspinevisible = false + else + error("Invalid spine identifier $s. Valid options are :l, :r, :b and :t.") end end end """ - space = tight_xticklabel_spacing!(ax::Axis) + space = tight_yticklabel_spacing!(ax::Axis) -Sets the space allocated for the xticklabels of the `Axis` to the minimum that is needed and returns that value. +Sets the space allocated for the yticklabels of the `Axis` to the minimum that is needed and returns that value. """ function tight_yticklabel_spacing!(ax::Axis) space = tight_ticklabel_spacing!(ax.yaxis) @@ -1200,7 +1186,7 @@ end """ space = tight_xticklabel_spacing!(ax::Axis) -Sets the space allocated for the yticklabels of the `Axis` to the minimum that is needed and returns that value. +Sets the space allocated for the xticklabels of the `Axis` to the minimum that is needed and returns that value. """ function tight_xticklabel_spacing!(ax::Axis) space = tight_ticklabel_spacing!(ax.xaxis) @@ -1208,6 +1194,8 @@ function tight_xticklabel_spacing!(ax::Axis) end """ + tight_ticklabel_spacing!(ax::Axis) + Sets the space allocated for the xticklabels and yticklabels of the `Axis` to the minimum that is needed. """ function tight_ticklabel_spacing!(ax::Axis) @@ -1233,7 +1221,7 @@ end function Makie.xlims!(ax::Axis, xlims) if length(xlims) != 2 error("Invalid xlims length of $(length(xlims)), must be 2.") - elseif xlims[1] == xlims[2] + elseif xlims[1] == xlims[2] && xlims[1] !== nothing error("Can't set x limits to the same value $(xlims[1]).") elseif all(x -> x isa Real, xlims) && xlims[1] > xlims[2] xlims = reverse(xlims) @@ -1241,8 +1229,9 @@ function Makie.xlims!(ax::Axis, xlims) else ax.xreversed[] = false end + mlims = convert_limit_attribute(ax.limits[]) - ax.limits.val = (xlims, ax.limits[][2]) + ax.limits.val = (xlims, mlims[2]) reset_limits!(ax, yauto = false) nothing end @@ -1250,7 +1239,7 @@ end function Makie.ylims!(ax::Axis, ylims) if length(ylims) != 2 error("Invalid ylims length of $(length(ylims)), must be 2.") - elseif ylims[1] == ylims[2] + elseif ylims[1] == ylims[2] && ylims[1] !== nothing error("Can't set y limits to the same value $(ylims[1]).") elseif all(x -> x isa Real, ylims) && ylims[1] > ylims[2] ylims = reverse(ylims) @@ -1258,22 +1247,95 @@ function Makie.ylims!(ax::Axis, ylims) else ax.yreversed[] = false end + mlims = convert_limit_attribute(ax.limits[]) - ax.limits.val = (ax.limits[][1], ylims) + ax.limits.val = (mlims[1], ylims) reset_limits!(ax, xauto = false) nothing end +""" + xlims!(ax, low, high) + xlims!(ax; low = nothing, high = nothing) + xlims!(ax, xlims) + +Set the x-axis limits of axis `ax` to `low` and `high` or a tuple +`xlims = (low,high)`. If the limits are ordered high-low, the axis orientation +will be reversed. If a limit is `nothing` it will be determined automatically +from the plots in the axis. +""" Makie.xlims!(ax, low, high) = Makie.xlims!(ax, (low, high)) +""" + ylims!(ax, low, high) + ylims!(ax; low = nothing, high = nothing) + ylims!(ax, ylims) + +Set the y-axis limits of axis `ax` to `low` and `high` or a tuple +`ylims = (low,high)`. If the limits are ordered high-low, the axis orientation +will be reversed. If a limit is `nothing` it will be determined automatically +from the plots in the axis. +""" Makie.ylims!(ax, low, high) = Makie.ylims!(ax, (low, high)) +""" + zlims!(ax, low, high) + zlims!(ax; low = nothing, high = nothing) + zlims!(ax, zlims) + +Set the z-axis limits of axis `ax` to `low` and `high` or a tuple +`zlims = (low,high)`. If the limits are ordered high-low, the axis orientation +will be reversed. If a limit is `nothing` it will be determined automatically +from the plots in the axis. +""" Makie.zlims!(ax, low, high) = Makie.zlims!(ax, (low, high)) +""" + xlims!(low, high) + xlims!(; low = nothing, high = nothing) + +Set the x-axis limits of the current axis to `low` and `high`. If the limits +are ordered high-low, this reverses the axis orientation. A limit set to +`nothing` will be determined automatically from the plots in the axis. +""" Makie.xlims!(low::Optional{<:Real}, high::Optional{<:Real}) = Makie.xlims!(current_axis(), low, high) +""" + ylims!(low, high) + ylims!(; low = nothing, high = nothing) + +Set the y-axis limits of the current axis to `low` and `high`. If the limits +are ordered high-low, this reverses the axis orientation. A limit set to +`nothing` will be determined automatically from the plots in the axis. +""" Makie.ylims!(low::Optional{<:Real}, high::Optional{<:Real}) = Makie.ylims!(current_axis(), low, high) +""" + zlims!(low, high) + zlims!(; low = nothing, high = nothing) + +Set the z-axis limits of the current axis to `low` and `high`. If the limits +are ordered high-low, this reverses the axis orientation. A limit set to +`nothing` will be determined automatically from the plots in the axis. +""" Makie.zlims!(low::Optional{<:Real}, high::Optional{<:Real}) = Makie.zlims!(current_axis(), low, high) +""" + xlims!(ax = current_axis()) + +Reset the x-axis limits to be determined automatically from the plots in the +axis. +""" Makie.xlims!(ax = current_axis(); low = nothing, high = nothing) = Makie.xlims!(ax, low, high) +""" + ylims!(ax = current_axis()) + +Reset the y-axis limits to be determined automatically from the plots in the +axis. +""" Makie.ylims!(ax = current_axis(); low = nothing, high = nothing) = Makie.ylims!(ax, low, high) +""" + zlims!(ax = current_axis()) + +Reset the z-axis limits to be determined automatically from the plots in the +axis. +""" Makie.zlims!(ax = current_axis(); low = nothing, high = nothing) = Makie.zlims!(ax, low, high) """ @@ -1311,20 +1373,10 @@ function limits!(ax::Axis, rect::Rect2) Makie.ylims!(ax, ymin, ymax) end -function limits!(args...) - limits!(current_axis(), args...) -end - -function Base.delete!(ax::Axis, plot::AbstractPlot) - delete!(ax.scene, plot) - ax -end - -function Base.empty!(ax::Axis) - while !isempty(ax.scene.plots) - delete!(ax, ax.scene.plots[end]) - end - ax +function limits!(args::Union{Nothing, Real, HyperRectangle}...) + axis = current_axis() + axis isa Nothing && error("There is no currently active axis!") + limits!(axis, args...) end Makie.transform_func(ax::Axis) = Makie.transform_func(ax.scene) @@ -1349,27 +1401,20 @@ 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) - return -end function attribute_examples(::Type{Axis}) Dict( @@ -1785,3 +1830,30 @@ function attribute_examples(::Type{Axis}) ], ) end + +function axis_bounds_with_decoration(axis::Axis) + # Filter out the zoomrect + background plot + lims = Makie.data_limits(axis.blockscene.plots, p -> p isa Mesh || p isa Poly) + return Makie.parent_transform(axis.blockscene) * lims +end + +""" + colorbuffer(ax::Axis; include_decorations=true, colorbuffer_kws...) + +Gets the colorbuffer of the `Axis` in `JuliaNative` image format. +If `include_decorations=false`, only the inside of the axis is fetched. +""" +function colorbuffer(ax::Axis; include_decorations=true, update=true, colorbuffer_kws...) + if update + update_state_before_display!(ax) + end + bb = if include_decorations + bb = axis_bounds_with_decoration(ax) + Rect2{Int}(round.(Int, minimum(bb)) .+ 1, round.(Int, widths(bb))) + else + viewport(ax.scene)[] + end + + img = colorbuffer(root(ax.scene); update=false, colorbuffer_kws...) + return get_sub_picture(img, JuliaNative, bb) +end diff --git a/src/makielayout/blocks/axis3d.jl b/src/makielayout/blocks/axis3d.jl index 2fc0fa72e62..ec72eb0dce7 100644 --- a/src/makielayout/blocks/axis3d.jl +++ b/src/makielayout/blocks/axis3d.jl @@ -38,12 +38,13 @@ function initialize_block!(ax::Axis3) return end - matrices = lift(calculate_matrices, scene, finallimits, scene.px_area, ax.elevation, ax.azimuth, - ax.perspectiveness, ax.aspect, ax.viewmode) + matrices = lift(calculate_matrices, scene, finallimits, scene.viewport, ax.elevation, ax.azimuth, + ax.perspectiveness, ax.aspect, ax.viewmode, ax.xreversed, ax.yreversed, ax.zreversed) - on(scene, matrices) do (view, proj, eyepos) + on(scene, matrices) do (model, view, proj, eyepos) cam = camera(scene) Makie.set_proj_view!(cam, proj, view) + scene.transformation.model[] = model cam.eyeposition[] = eyepos end @@ -67,30 +68,23 @@ function initialize_block!(ax::Axis3) add_panel!(scene, ax, 1, 3, 2, finallimits, mi2) xgridline1, xgridline2, xframelines = - add_gridlines_and_frames!(blockscene, scene, ax, 1, finallimits, ticknode_1, mi1, mi2, mi3) + add_gridlines_and_frames!(blockscene, scene, ax, 1, finallimits, ticknode_1, mi1, mi2, mi3, ax.xreversed, ax.yreversed, ax.zreversed) ygridline1, ygridline2, yframelines = - add_gridlines_and_frames!(blockscene, scene, ax, 2, finallimits, ticknode_2, mi2, mi1, mi3) + add_gridlines_and_frames!(blockscene, scene, ax, 2, finallimits, ticknode_2, mi2, mi1, mi3, ax.xreversed, ax.yreversed, ax.zreversed) zgridline1, zgridline2, zframelines = - add_gridlines_and_frames!(blockscene, scene, ax, 3, finallimits, ticknode_3, mi3, mi1, mi2) + add_gridlines_and_frames!(blockscene, scene, ax, 3, finallimits, ticknode_3, mi3, mi1, mi2, ax.xreversed, ax.yreversed, ax.zreversed) xticks, xticklabels, xlabel = - add_ticks_and_ticklabels!(blockscene, scene, ax, 1, finallimits, ticknode_1, mi1, mi2, mi3, ax.azimuth) + add_ticks_and_ticklabels!(blockscene, scene, ax, 1, finallimits, ticknode_1, mi1, mi2, mi3, ax.azimuth, ax.xreversed, ax.yreversed, ax.zreversed) yticks, yticklabels, ylabel = - add_ticks_and_ticklabels!(blockscene, scene, ax, 2, finallimits, ticknode_2, mi2, mi1, mi3, ax.azimuth) + add_ticks_and_ticklabels!(blockscene, scene, ax, 2, finallimits, ticknode_2, mi2, mi1, mi3, ax.azimuth, ax.xreversed, ax.yreversed, ax.zreversed) zticks, zticklabels, zlabel = - add_ticks_and_ticklabels!(blockscene, scene, ax, 3, finallimits, ticknode_3, mi3, mi1, mi2, ax.azimuth) + add_ticks_and_ticklabels!(blockscene, scene, ax, 3, finallimits, ticknode_3, mi3, mi1, mi2, ax.azimuth, ax.xreversed, ax.yreversed, ax.zreversed) - titlepos = lift(scene, scene.px_area, ax.titlegap, ax.titlealign) do a, titlegap, align + titlepos = lift(scene, scene.viewport, ax.titlegap, ax.titlealign) do a, titlegap, align - x = if align === :center - a.origin[1] + a.widths[1] / 2 - elseif align === :left - a.origin[1] - elseif align === :right - a.origin[1] + a.widths[1] - else - error("Title align $align not supported.") - end + align_factor = halign2num(align, "Horizontal title align $align not supported.") + x = a.origin[1] + align_factor * a.widths[1] yoffset = top(a) + titlegap @@ -112,9 +106,6 @@ function initialize_block!(ax::Axis3) markerspace = :data, inspectable = false) - ax.cycler = Cycler() - ax.palette = Makie.DEFAULT_PALETTES - ax.mouseeventhandle = addmouseevents!(scene) scrollevents = Observable(ScrollEvent(0, 0)) setfield!(ax, :scrollevents, scrollevents) @@ -171,12 +162,26 @@ function initialize_block!(ax::Axis3) return end -can_be_current_axis(ax3::Axis3) = true +function calculate_matrices(limits, viewport, elev, azim, perspectiveness, aspect, + viewmode, xreversed, yreversed, zreversed) -function calculate_matrices(limits, px_area, elev, azim, perspectiveness, aspect, - viewmode) + ori = limits.origin ws = widths(limits) + limits = Rect3f( + ( + ori[1] + (xreversed ? ws[1] : zero(ws[1])), + ori[2] + (yreversed ? ws[2] : zero(ws[2])), + ori[3] + (zreversed ? ws[3] : zero(ws[3])), + ), + ( + ws[1] * (xreversed ? -1 : 1), + ws[2] * (yreversed ? -1 : 1), + ws[3] * (zreversed ? -1 : 1), + ) + ) + + ws = widths(limits) t = Makie.translationmatrix(-Float64.(limits.origin)) s = if aspect === :equal @@ -190,7 +195,7 @@ function calculate_matrices(limits, px_area, elev, azim, perspectiveness, aspect end |> Makie.scalematrix t2 = Makie.translationmatrix(-0.5 .* ws .* scales) - scale_matrix = t2 * s * t + model = t2 * s * t ang_max = 90 ang_min = 0.5 @@ -211,23 +216,16 @@ function calculate_matrices(limits, px_area, elev, azim, perspectiveness, aspect eyepos = Vec3{Float64}(x, y, z) - lookat_matrix = Makie.lookat( - eyepos, - Vec3{Float64}(0, 0, 0), - Vec3{Float64}(0, 0, 1)) + lookat_matrix = lookat(eyepos, Vec3{Float64}(0), Vec3{Float64}(0, 0, 1)) - w = width(px_area) - h = height(px_area) + w = width(viewport) + h = height(viewport) - view_matrix = lookat_matrix * scale_matrix + projection_matrix = projectionmatrix( + lookat_matrix * model, limits, eyepos, radius, azim, elev, angle, + w, h, scales, viewmode) - projection_matrix = projectionmatrix(view_matrix, limits, eyepos, radius, azim, elev, angle, w, h, scales, viewmode) - - # for eyeposition dependent algorithms, we need to present the position as if - # there was no scaling applied - eyeposition = Vec3f(inv(scale_matrix) * Vec4f(eyepos..., 1)) - - view_matrix, projection_matrix, eyeposition + return model, lookat_matrix, projection_matrix, eyepos end function projectionmatrix(viewmatrix, limits, eyepos, radius, azim, elev, angle, width, height, scales, viewmode) @@ -269,33 +267,6 @@ function projectionmatrix(viewmatrix, limits, eyepos, radius, azim, elev, angle, end end - -function Makie.plot!( - ax::Axis3, P::Makie.PlotFunc, - attributes::Makie.Attributes, args...; - kw_attributes...) - - allattrs = merge(attributes, Attributes(kw_attributes)) - - _disallow_keyword(:axis, allattrs) - _disallow_keyword(:figure, allattrs) - - cycle = get_cycle_for_plottype(allattrs, P) - add_cycle_attributes!(allattrs, P, cycle, ax.cycler, ax.palette) - - plot = Makie.plot!(ax.scene, P, allattrs, args...) - - if is_open_or_any_parent(ax.scene) - reset_limits!(ax) - end - plot -end - -function Makie.plot!(P::Makie.PlotFunc, ax::Axis3, args...; kw_attributes...) - attributes = Makie.Attributes(kw_attributes) - Makie.plot!(ax, P, attributes, args...) -end - function update_state_before_display!(ax::Axis3) reset_limits!(ax) return @@ -349,10 +320,6 @@ function getlimits(ax::Axis3, dim) templim end -# mutable struct LineAxis3D - -# end - function dimpoint(dim, v, v1, v2) if dim == 1 Point(v, v1, v2) @@ -383,7 +350,7 @@ function dim2(dim) end end -function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, ticknode, miv, min1, min2) +function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, ticknode, miv, min1, min2, xreversed, yreversed, zreversed) dimsym(sym) = Symbol(string((:x, :y, :z)[dim]) * string(sym)) attr(sym) = getproperty(ax, dimsym(sym)) @@ -392,11 +359,14 @@ function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, tickno d1 = dim1(dim) d2 = dim2(dim) + tickvalues = @lift($ticknode[1]) - endpoints = lift(limits, tickvalues, min1, min2) do lims, ticks, min1, min2 - f1 = min1 ? minimum(lims)[d1] : maximum(lims)[d1] - f2 = min2 ? minimum(lims)[d2] : maximum(lims)[d2] + endpoints = lift(limits, tickvalues, min1, min2, xreversed, yreversed, zreversed) do lims, ticks, min1, min2, xrev, yrev, zrev + rev1 = (xrev, yrev, zrev)[d1] + rev2 = (xrev, yrev, zrev)[d2] + f1 = min1 ⊻ rev1 ? minimum(lims)[d1] : maximum(lims)[d1] + f2 = min2 ⊻ rev2 ? minimum(lims)[d2] : maximum(lims)[d2] # from tickvalues and f1 and min2:max2 mi = minimum(lims) ma = maximum(lims) @@ -409,9 +379,11 @@ function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, tickno xautolimits = false, yautolimits = false, zautolimits = false, transparency = true, visible = attr(:gridvisible), inspectable = false) - endpoints2 = lift(limits, tickvalues, min1, min2) do lims, ticks, min1, min2 - f1 = min1 ? minimum(lims)[d1] : maximum(lims)[d1] - f2 = min2 ? minimum(lims)[d2] : maximum(lims)[d2] + endpoints2 = lift(limits, tickvalues, min1, min2, xreversed, yreversed, zreversed) do lims, ticks, min1, min2, xrev, yrev, zrev + rev1 = (xrev, yrev, zrev)[d1] + rev2 = (xrev, yrev, zrev)[d2] + f1 = min1 ⊻ rev1 ? minimum(lims)[d1] : maximum(lims)[d1] + f2 = min2 ⊻ rev2 ? minimum(lims)[d2] : maximum(lims)[d2] # from tickvalues and f1 and min2:max2 mi = minimum(lims) ma = maximum(lims) @@ -425,10 +397,16 @@ function add_gridlines_and_frames!(topscene, scene, ax, dim::Int, limits, tickno visible = attr(:gridvisible), inspectable = false) - framepoints = lift(limits, scene.camera.projectionview, scene.px_area, min1, min2 - ) do lims, _, pxa, mi1, mi2 + framepoints = lift(limits, scene.camera.projectionview, scene.viewport, min1, min2, xreversed, yreversed, zreversed + ) do lims, _, pxa, mi1, mi2, xrev, yrev, zrev o = pxa.origin + rev1 = (xrev, yrev, zrev)[d1] + rev2 = (xrev, yrev, zrev)[d2] + + mi1 = mi1 ⊻ rev1 + mi2 = mi2 ⊻ rev2 + f(mi) = mi ? minimum : maximum p1 = dpoint(minimum(lims)[dim], f(!mi1)(lims)[d1], f(mi2)(lims)[d2]) p2 = dpoint(maximum(lims)[dim], f(!mi1)(lims)[d1], f(mi2)(lims)[d2]) @@ -456,14 +434,14 @@ end # this function projects a point from a 3d subscene into the parent space with a really # small z value function to_topscene_z_2d(p3d, scene) - o = scene.px_area[].origin + o = scene.viewport[].origin p2d = Point2f(o + Makie.project(scene, p3d)) # -10000 is an arbitrary weird constant that in preliminary testing didn't seem # to clip into plot objects anymore Point3f(p2d..., -10000) end -function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, ticknode, miv, min1, min2, azimuth) +function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, ticknode, miv, min1, min2, azimuth, xreversed, yreversed, zreversed) dimsym(sym) = Symbol(string((:x, :y, :z)[dim]) * string(sym)) attr(sym) = getproperty(ax, dimsym(sym)) @@ -477,63 +455,72 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno map!(ticklabels, ticknode) do (values, labels) labels end + ticksize = attr(:ticksize) tick_segments = lift(topscene, limits, tickvalues, miv, min1, min2, - scene.camera.projectionview, scene.px_area) do lims, ticks, miv, min1, min2, - pview, pxa - f1 = !min1 ? minimum(lims)[d1] : maximum(lims)[d1] - f2 = min2 ? minimum(lims)[d2] : maximum(lims)[d2] + scene.camera.projectionview, scene.viewport, ticksize, xreversed, yreversed, zreversed) do lims, ticks, miv, min1, min2, + pview, pxa, tsize, xrev, yrev, zrev + + rev1 = (xrev, yrev, zrev)[d1] + rev2 = (xrev, yrev, zrev)[d2] + + f1 = !(min1 ⊻ rev1) ? minimum(lims)[d1] : maximum(lims)[d1] + f2 = (min2 ⊻ rev2) ? minimum(lims)[d2] : maximum(lims)[d2] - f1_oppo = min1 ? minimum(lims)[d1] : maximum(lims)[d1] - f2_oppo = !min2 ? minimum(lims)[d2] : maximum(lims)[d2] + f1_oppo = (min1 ⊻ rev1) ? minimum(lims)[d1] : maximum(lims)[d1] + f2_oppo = !(min2 ⊻ rev2) ? minimum(lims)[d2] : maximum(lims)[d2] diff_f1 = f1 - f1_oppo diff_f2 = f2 - f2_oppo - map(ticks) do t + o = pxa.origin + + return map(ticks) do t p1 = dpoint(t, f1, f2) p2 = if dim == 3 # special case the z axis, here it depends on azimuth in which direction the ticks go if 45 <= mod1(rad2deg(azimuth[]), 180) <= 135 - dpoint(t, f1 + 0.03 * diff_f1, f2) + dpoint(t, f1 + diff_f1, f2) else - dpoint(t, f1, f2 + 0.03 * diff_f2) + dpoint(t, f1, f2 + diff_f2) end else - dpoint(t, f1 + 0.03 * diff_f1, f2) + dpoint(t, f1 + diff_f1, f2) end - (p1, p2) - end - end + pp1 = Point2f(o + Makie.project(scene, p1)) + pp2 = Point2f(o + Makie.project(scene, p2)) + diff_pp = Makie.GeometryBasics.normalize(Point2f(pp2 - pp1)) + return (pp1, pp1 .+ Float32(tsize) .* diff_pp) + end + end # we are going to transform the 3d tick segments into 2d of the topscene # because otherwise they # be cut when they extend beyond the scene boundary - tick_segments_2dz = lift(topscene, tick_segments, scene.camera.projectionview, scene.px_area) do ts, _, _ + tick_segments_2dz = lift(topscene, tick_segments, scene.camera.projectionview, scene.viewport) do ts, _, _ map(ts) do p1_p2 to_topscene_z_2d.(p1_p2, Ref(scene)) end end - ticks = linesegments!(topscene, tick_segments_2dz, + ticks = linesegments!(topscene, tick_segments, xautolimits = false, yautolimits = false, zautolimits = false, transparency = true, inspectable = false, color = attr(:tickcolor), linewidth = attr(:tickwidth), visible = attr(:ticksvisible)) + # -10000 is an arbitrary weird constant that in preliminary testing didn't seem + # to clip into plot objects anymore + translate!(ticks, 0, 0, -10000) labels_positions = Observable{Any}() - map!(topscene, labels_positions, scene.px_area, scene.camera.projectionview, + map!(topscene, labels_positions, scene.viewport, scene.camera.projectionview, tick_segments, ticklabels, attr(:ticklabelpad)) do pxa, pv, ticksegs, ticklabs, pad o = pxa.origin points = map(ticksegs) do (tstart, tend) - tstartp = Point2f(o + Makie.project(scene, tstart)) - tendp = Point2f(o + Makie.project(scene, tend)) - - offset = pad * Makie.GeometryBasics.normalize( - Point2f(tendp - tstartp)) - tendp + offset + offset = pad * Makie.GeometryBasics.normalize(Point2f(tend - tstart)) + tend + offset end N = min(length(ticklabs), length(points)) @@ -562,14 +549,21 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno label_align = Observable((:center, :top)) onany(topscene, - scene.px_area, scene.camera.projectionview, limits, miv, min1, min2, - attr(:labeloffset), attr(:labelrotation), attr(:labelalign) - ) do pxa, pv, lims, miv, min1, min2, labeloffset, lrotation, lalign + scene.viewport, scene.camera.projectionview, limits, miv, min1, min2, + attr(:labeloffset), attr(:labelrotation), attr(:labelalign), xreversed, yreversed, zreversed + ) do pxa, pv, lims, miv, min1, min2, labeloffset, lrotation, lalign, xrev, yrev, zrev o = pxa.origin - f1 = !min1 ? minimum(lims)[d1] : maximum(lims)[d1] - f2 = min2 ? minimum(lims)[d2] : maximum(lims)[d2] + rev1 = (xrev, yrev, zrev)[d1] + rev2 = (xrev, yrev, zrev)[d2] + revdim = (xrev, yrev, zrev)[dim] + + minr1 = min1 ⊻ rev1 + minr2 = min2 ⊻ rev2 + + f1 = !minr1 ? minimum(lims)[d1] : maximum(lims)[d1] + f2 = minr2 ? minimum(lims)[d2] : maximum(lims)[d2] # get end points of axis p1 = dpoint(minimum(lims)[dim], f1, f2) @@ -586,9 +580,9 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno diff = pp2 - pp1 diffsign = if dim == 1 || dim == 3 - !(min1 ⊻ min2) ? 1 : -1 + !(min1 ⊻ min2 ⊻ revdim) ? 1 : -1 else - (min1 ⊻ min2) ? 1 : -1 + (min1 ⊻ min2 ⊻ revdim) ? 1 : -1 end a = pi/2 @@ -609,7 +603,6 @@ function add_ticks_and_ticklabels!(topscene, scene, ax, dim::Int, limits, tickno if slight_flip offset_ang_90deg_alwaysup += pi end - offset_ang_90deg_alwaysup labelrotation = if lrotation == Makie.automatic offset_ang_90deg_alwaysup @@ -684,7 +677,7 @@ function add_panel!(scene, ax, dim1, dim2, dim3, limits, min3) faces = [1 2 3; 3 4 1] - panel = mesh!(scene, vertices, faces, shading = false, inspectable = false, + panel = mesh!(scene, vertices, faces, shading = NoShading, inspectable = false, xautolimits = false, yautolimits = false, zautolimits = false, color = attr(:panelcolor), visible = attr(:panelvisible)) return panel @@ -740,6 +733,12 @@ function hideydecorations!(ax::Axis3; ax end +""" + hidezdecorations!(ax::Axis3; label = true, ticklabels = true, ticks = true, grid = true) + +Hide decorations of the z-axis: label, ticklabels, ticks and grid. Keyword +arguments can be used to disable hiding of certain types of decorations. +""" function hidezdecorations!(ax::Axis3; label = true, ticklabels = true, ticks = true, grid = true) @@ -842,16 +841,17 @@ end function Makie.xlims!(ax::Axis3, xlims::Tuple{Union{Real, Nothing}, Union{Real, Nothing}}) if length(xlims) != 2 error("Invalid xlims length of $(length(xlims)), must be 2.") - elseif xlims[1] == xlims[2] + elseif xlims[1] == xlims[2] && xlims[1] !== nothing error("Can't set x limits to the same value $(xlims[1]).") - # elseif all(x -> x isa Real, xlims) && xlims[1] > xlims[2] - # xlims = reverse(xlims) - # ax.xreversed[] = true - # else - # ax.xreversed[] = false + elseif all(x -> x isa Real, xlims) && xlims[1] > xlims[2] + xlims = reverse(xlims) + ax.xreversed[] = true + else + ax.xreversed[] = false end + mlims = convert_limit_attribute(ax.limits[]) - ax.limits.val = (xlims, ax.limits[][2], ax.limits[][3]) + ax.limits.val = (xlims, mlims[2], mlims[3]) reset_limits!(ax, yauto = false, zauto = false) nothing end @@ -859,16 +859,17 @@ end function Makie.ylims!(ax::Axis3, ylims::Tuple{Union{Real, Nothing}, Union{Real, Nothing}}) if length(ylims) != 2 error("Invalid ylims length of $(length(ylims)), must be 2.") - elseif ylims[1] == ylims[2] + elseif ylims[1] == ylims[2] && ylims[1] !== nothing error("Can't set y limits to the same value $(ylims[1]).") - # elseif all(x -> x isa Real, ylims) && ylims[1] > ylims[2] - # ylims = reverse(ylims) - # ax.yreversed[] = true - # else - # ax.yreversed[] = false + elseif all(x -> x isa Real, ylims) && ylims[1] > ylims[2] + ylims = reverse(ylims) + ax.yreversed[] = true + else + ax.yreversed[] = false end + mlims = convert_limit_attribute(ax.limits[]) - ax.limits.val = (ax.limits[][1], ylims, ax.limits[][3]) + ax.limits.val = (mlims[1], ylims, mlims[3]) reset_limits!(ax, xauto = false, zauto = false) nothing end @@ -876,25 +877,26 @@ end function Makie.zlims!(ax::Axis3, zlims) if length(zlims) != 2 error("Invalid zlims length of $(length(zlims)), must be 2.") - elseif zlims[1] == zlims[2] - error("Can't set y limits to the same value $(zlims[1]).") - # elseif all(x -> x isa Real, zlims) && zlims[1] > zlims[2] - # zlims = reverse(zlims) - # ax.zreversed[] = true - # else - # ax.zreversed[] = false + elseif zlims[1] == zlims[2] && zlims[1] !== nothing + error("Can't set z limits to the same value $(zlims[1]).") + elseif all(x -> x isa Real, zlims) && zlims[1] > zlims[2] + zlims = reverse(zlims) + ax.zreversed[] = true + else + ax.zreversed[] = false end + mlims = convert_limit_attribute(ax.limits[]) - ax.limits.val = (ax.limits[][1], ax.limits[][2], zlims) + ax.limits.val = (mlims[1], mlims[2], zlims) reset_limits!(ax, xauto = false, yauto = false) nothing end """ - limits!(ax::Axis3, xlims, ylims) + limits!(ax::Axis3, xlims, ylims, zlims) -Set the axis limits to `xlims` and `ylims`. +Set the axis limits to `xlims`, `ylims`, and `zlims`. If limits are ordered high-low, this reverses the axis orientation. """ function limits!(ax::Axis3, xlims, ylims, zlims) @@ -906,7 +908,8 @@ end """ limits!(ax::Axis3, x1, x2, y1, y2, z1, z2) -Set the axis x-limits to `x1` and `x2` and the y-limits to `y1` and `y2`. +Set the axis x-limits to `x1` and `x2`, the y-limits to `y1` and `y2`, and the +z-limits to `z1` and `z2`. If limits are ordered high-low, this reverses the axis orientation. """ function limits!(ax::Axis3, x1, x2, y1, y2, z1, z2) @@ -1035,5 +1038,93 @@ function attribute_examples(::Type{Axis3}) """ ), ], + :xreversed => [ + Example( + name = "`xreversed` on and off", + code = """ + using FileIO + + fig = Figure() + + brain = load(assetpath("brain.stl")) + + ax1 = Axis3(fig[1, 1], title = "xreversed = false") + ax2 = Axis3(fig[2, 1], title = "xreversed = true", xreversed = true) + for ax in [ax1, ax2] + mesh!(ax, brain, color = getindex.(brain.position, 1)) + end + + fig + """ + ), + ], + :yreversed => [ + Example( + name = "`yreversed` on and off", + code = """ + using FileIO + + fig = Figure() + + brain = load(assetpath("brain.stl")) + + ax1 = Axis3(fig[1, 1], title = "yreversed = false") + ax2 = Axis3(fig[2, 1], title = "yreversed = true", yreversed = true) + for ax in [ax1, ax2] + mesh!(ax, brain, color = getindex.(brain.position, 2)) + end + + fig + """ + ), + ], + :zreversed => [ + Example( + name = "`zreversed` on and off", + code = """ + using FileIO + + fig = Figure() + + brain = load(assetpath("brain.stl")) + + ax1 = Axis3(fig[1, 1], title = "zreversed = false") + ax2 = Axis3(fig[2, 1], title = "zreversed = true", zreversed = true) + for ax in [ax1, ax2] + mesh!(ax, brain, color = getindex.(brain.position, 3)) + end + + fig + """ + ), + ], + :protrusions => [ + Example( + name = "Single protrusion", + code = """ + fig = Figure(backgroundcolor = :gray97) + Box(fig[1, 1], strokewidth = 0) # visualizes the layout cell + Axis3(fig[1, 1], protrusions = 100, viewmode = :stretch, + title = "protrusions = 100") + fig + """ + ), + Example( + name = "Removing protrusions", + code = """ + fig = Figure(backgroundcolor = :gray97) + Box(fig[1, 1], strokewidth = 0) # visualizes the layout cell + ax = Axis3(fig[1, 1], protrusions = (0, 0, 0, 20), viewmode = :stretch, + title = "protrusions = (0, 0, 0, 20)") + hidedecorations!(ax) + fig + """ + ), + ] ) end + + +# Axis interface + +tightlimits!(ax::Axis3) = nothing # TODO, not implemented yet diff --git a/src/makielayout/blocks/box.jl b/src/makielayout/blocks/box.jl index 11f9a115231..77dd49be62f 100644 --- a/src/makielayout/blocks/box.jl +++ b/src/makielayout/blocks/box.jl @@ -5,14 +5,108 @@ function initialize_block!(box::Box) vis ? col : RGBAf(0, 0, 0, 0) end - ibbox = lift(round_to_IRect2D, blockscene, box.layoutobservables.computedbbox) + path = lift(blockscene, box.layoutobservables.computedbbox, box.cornerradius) do bbox, r + if r == 0 + BezierPath([ + MoveTo(topright(bbox)), + LineTo(topleft(bbox)), + LineTo(bottomleft(bbox)), + LineTo(bottomright(bbox)), + ClosePath() + ]) + else + w, h = widths(bbox) + _max = min(w/2, h/2) + r1, r2, r3, r4 = r isa NTuple{4, Real} ? r : r isa Real ? (r, r, r, r) : throw(ArgumentError("Invalid cornerradius value $r. Must be a `Real` or a tuple with 4 `Real`s.")) - poly!(blockscene, ibbox, color = box.color, visible = box.visible, + r1, r2, r3, r4 = min.(_max, (r1, r2, r3, r4)) + BezierPath([ + MoveTo(bbox.origin + Point(w, h/2)), + EllipticalArc(topright(bbox) - Point2f(r1, r1), r1, r1, 0.0, 0, pi/2), + EllipticalArc(topleft(bbox) + Point2f(r4, -r4), r4, r4, 0.0, pi/2, pi), + EllipticalArc(bottomleft(bbox) + Point2f(r3, r3), r3, r3, 0.0, pi, 3/2 * pi), + EllipticalArc(bottomright(bbox) + Point2f(-r2, r2), r2, r2, 0.0, 3/2 * pi, 2pi), + ClosePath(), + ]) + end + end + + + + poly!(blockscene, path, color = box.color, visible = box.visible, strokecolor = strokecolor_with_visibility, strokewidth = box.strokewidth, - inspectable = false) + inspectable = false, linestyle = box.linestyle) # trigger bbox box.layoutobservables.suggestedbbox[] = box.layoutobservables.suggestedbbox[] return end + + +function attribute_examples(::Type{Box}) + Dict( + :color => [ + Example( + name = "Colors", + code = """ + fig = Figure() + Box(fig[1, 1], color = :red) + Box(fig[1, 2], color = (:red, 0.5)) + Box(fig[2, 1], color = RGBf(0.2, 0.5, 0.7)) + Box(fig[2, 2], color = RGBAf(0.2, 0.5, 0.7, 0.5)) + fig + """ + ) + ], + :strokecolor => [ + Example( + name = "Stroke colors", + code = """ + fig = Figure() + Box(fig[1, 1], strokecolor = :red) + Box(fig[1, 2], strokecolor = (:red, 0.5)) + Box(fig[2, 1], strokecolor = RGBf(0.2, 0.5, 0.7)) + Box(fig[2, 2], strokecolor = RGBAf(0.2, 0.5, 0.7, 0.5)) + fig + """ + ) + ], + :strokewidth => [ + Example( + name = "Stroke widths", + code = """ + fig = Figure() + Box(fig[1, 1], strokewidth = 1) + Box(fig[1, 2], strokewidth = 10) + Box(fig[1, 3], strokewidth = 0) + fig + """ + ) + ], + :linestyle => [ + Example( + name = "Stroke style", + code = """ + fig = Figure() + Box(fig[1, 1], linestyle = :solid) + Box(fig[1, 2], linestyle = :dot) + Box(fig[1, 3], linestyle = :dash) + fig + """ + ) + ], + :cornerradius => [ + Example( + name = "Corner radius", + code = """ + fig = Figure() + Box(fig[1, 1], cornerradius = 0) + Box(fig[1, 2], cornerradius = 20) + Box(fig[1, 3], cornerradius = (0, 10, 20, 30)) + fig + """ + ) + ], + ) +end \ No newline at end of file diff --git a/src/makielayout/blocks/colorbar.jl b/src/makielayout/blocks/colorbar.jl index 1f84dfca08b..9032a15a888 100644 --- a/src/makielayout/blocks/colorbar.jl +++ b/src/makielayout/blocks/colorbar.jl @@ -17,75 +17,111 @@ function block_docs(::Type{Colorbar}) """ end - -function Colorbar(fig_or_scene, plot::AbstractPlot; kwargs...) - - for key in (:colormap, :limits) - if key in keys(kwargs) +function colorbar_check(keys, kwargs_keys) + for key in keys + if key in kwargs_keys error("You should not pass the `$key` attribute to the colorbar when constructing it using an existing plot object. This attribute is copied from the plot object, and setting it from the colorbar will make the plot object and the colorbar go out of sync.") end end - - Colorbar( - fig_or_scene; - colormap = plot.colormap, - limits = plot.colorrange, - kwargs... - ) end -function Colorbar(fig_or_scene, heatmap::Union{Heatmap, Image}; kwargs...) +function extract_colorrange(@nospecialize(plot::AbstractPlot))::Vec2{Float64} + if haskey(plot, :calculated_colors) && plot.calculated_colors[] isa Makie.ColorMapping + return plot.calculated_colors[].colorrange[] + elseif haskey(plot, :colorrange) && !(plot.colorrange[] isa Makie.Automatic) + return plot.colorrange[] + else + error("colorrange not found and calculated_colors for the plot is missing or is not a proper color map. Heatmaps and images should always contain calculated_colors[].colorrange") + end +end - for key in (:colormap, :limits, :highclip, :lowclip) - if key in keys(kwargs) - error("You should not pass the `$key` attribute to the colorbar when constructing it using an existing plot object. This attribute is copied from the plot object, and setting it from the colorbar will make the plot object and the colorbar go out of sync.") - end +function extract_colormap(@nospecialize(plot::AbstractPlot)) + has_colorrange = haskey(plot, :colorrange) && !(plot.colorrange[] isa Makie.Automatic) + if haskey(plot, :calculated_colors) && plot.calculated_colors[] isa Makie.ColorMapping + return plot.calculated_colors[] + elseif has_colorrange && all(x -> haskey(plot, x), [:colormap, :colorrange, :color]) && plot.color[] isa AbstractVector{<:Colorant} + return ColorMapping( + plot.color[], plot.color, plot.colormap, plot.colorrange, + get(plot, :colorscale, Observable(identity)), + get(plot, :alpha, Observable(1.0)), + get(plot, :highclip, Observable(automatic)), + get(plot, :lowclip, Observable(automatic)), + get(plot, :nan_color, Observable(RGBAf(0,0,0,0))), + ) + else + return nothing end +end - Colorbar( - fig_or_scene; - colormap = heatmap.colormap, - limits = heatmap.colorrange, - highclip = heatmap.highclip, - lowclip = heatmap.lowclip, - kwargs... - ) +function extract_colormap(plot::Union{Arrows, StreamPlot}) + return extract_colormap(plot.plots[1]) end -function Colorbar(fig_or_scene, contourf::Union{Contourf, Tricontourf}; kwargs...) +function extract_colormap(plot::Plot{volumeslices}) + return extract_colormap(plot.plots[1]) +end - for key in (:colormap, :limits, :highclip, :lowclip) - if key in keys(kwargs) - error("You should not pass the `$key` attribute to the colorbar when constructing it using an existing plot object. This attribute is copied from the plot object, and setting it from the colorbar will make the plot object and the colorbar go out of sync.") +function extract_colormap(plot::Union{Contourf,Tricontourf}) + levels = plot._computed_levels + limits = lift(l -> (l[1], l[end]), levels) + function extend_color(color, computed) + color === nothing && return automatic + color == :auto || color == automatic && return computed + return computed + end + elow = lift(extend_color, plot.extendlow, plot._computed_extendlow) + ehigh = lift(extend_color, plot.extendhigh, plot._computed_extendhigh) + return ColorMapping(levels[], levels, plot._computed_colormap, limits, plot.colorscale, Observable(1.0), + elow, ehigh, plot.nan_color) +end + + +function extract_colormap_recursive(@nospecialize(plot::T)) where {T <: AbstractPlot} + cmap = extract_colormap(plot) + if !isnothing(cmap) + return cmap + else + colormaps = [extract_colormap_recursive(child) for child in plot.plots] + if length(colormaps) == 1 + return colormaps[1] + elseif isempty(colormaps) + return nothing + else + # Prefer ColorMapping if in doubt! + cmaps = filter(x-> x isa ColorMapping, colormaps) + length(cmaps) == 1 && return cmaps[1] + error("Multiple colormaps found for plot $(plot), please specify which one to use manually. Please overload `Makie.extract_colormap(::$(T))` to allow for the automatical creation of a Colorbar.") end end +end - steps = contourf._computed_levels +function Colorbar(fig_or_scene, plot::AbstractPlot; kwargs...) + colorbar_check((:colormap, :limits, :highclip, :lowclip), keys(kwargs)) + cmap = extract_colormap_recursive(plot) + func = plotfunc(plot) + if isnothing(cmap) + error("Neither $(func) nor any of its children use a colormap. Cannot create a Colorbar from this plot, please create it manually. + If this is a recipe, one needs to overload `Makie.extract_colormap(::$(Plot{func}))` to allow for the automatical creation of a Colorbar.") + end + if !(cmap isa ColorMapping) + error("extract_colormap(::$(Plot{func})) returned an invalid value: $cmap. Needs to return either a `Makie.ColorMapping`.") + end - limits = lift(steps) do steps - steps[1], steps[end] + if to_value(cmap.color) isa Union{AbstractVector{<: Colorant}, Colorant} + error("""Plot $(func)'s color attribute uses colors directly, so it can't be used to create a Colorbar, since no numbers are mapped to a color via the colormap. + Please create the colorbar manually e.g. via `Colorbar(f[1, 2], colorrange=the_range, colormap=the_colormap)`.. + """) end - Colorbar( + return Colorbar( fig_or_scene; - colormap = contourf._computed_colormap, - limits = limits, - lowclip = contourf._computed_extendlow, - highclip = contourf._computed_extendhigh, + colormap=cmap, kwargs... ) - end - function initialize_block!(cb::Colorbar) blockscene = cb.blockscene - limits = lift(blockscene, cb.limits, cb.colorrange) do limits, colorrange - if all(!isnothing, (limits, colorrange)) - error("Both colorrange + limits are set, please only set one, they're aliases. colorrange: $(colorrange), limits: $(limits)") - end - return something(limits, colorrange, (0, 1)) - end onany(blockscene, cb.size, cb.vertical) do sz, vertical if vertical @@ -97,132 +133,139 @@ function initialize_block!(cb::Colorbar) framebox = lift(round_to_IRect2D, blockscene, cb.layoutobservables.computedbbox) - cgradient = Observable{PlotUtils.ColorGradient}() - map!(blockscene, cgradient, cb.colormap) do cmap - if cmap isa PlotUtils.ColorGradient - # if we have a colorgradient directly, we want to keep it intact - # to enable correct categorical colormap behavior etc - return cmap - else - # this is a bit weird, first convert to a vector of colors, - # then use cgrad, but at least I can use `get` on that later - converted = Makie.to_colormap(cmap) - return cgrad(converted) + # TODO, always convert to ColorMapping! + if cb.colormap[] isa ColorMapping + cmap = cb.colormap[] + else + # Old way without Colormapping. We keep it, to be able to create a colormap directly + limits = lift(blockscene, cb.limits, cb.colorrange) do limits, colorrange + if all(!isnothing, (limits, colorrange)) + error("Both colorrange + limits are set, please only set one, they're aliases. colorrange: $(colorrange), limits: $(limits)") + end + return something(limits, colorrange, (0, 1)) end + alpha = Observable(1.0) # dont have these as fields in Colorbar + nan_color = Observable(RGBAf(0, 0, 0, 0)) + cmap = ColorMapping( + Float64[], Observable(Float64[]), cb.colormap, limits, + cb.scale, alpha, cb.lowclip, cb.highclip, nan_color) end - function isvisible(x, compare) - x isa Automatic && return false - x isa Nothing && return false - c = to_color(x) - alpha(c) <= 0.0 && return false - return c != compare - end - - lowclip_tri_visible = lift(isvisible, blockscene, cb.lowclip, lift(x-> get(x, 0), blockscene, cgradient)) - highclip_tri_visible = lift(isvisible, blockscene, cb.highclip, lift(x-> get(x, 1), blockscene, cgradient)) - - tri_heights = lift(blockscene, highclip_tri_visible, lowclip_tri_visible, framebox) do hv, lv, box - if cb.vertical[] - (lv * width(box), hv * width(box)) + colormap = lift(cmap.raw_colormap, cmap.colormap, cmap.mapping) do rcm, cm, mapping + if isnothing(mapping) + return rcm else - (lv * height(box), hv * height(box)) - end .* sin(pi/3) + # if there is a mapping, we want to apply it to the colormap, which is already done for cmap.colormap (by calling to_colormap(cgrad(...))) + # In the future, we may want to use cmap.mapping to do this ourselves + return cm + end end - - barsize = lift(blockscene, tri_heights) do heights - if cb.vertical[] - max(1, height(framebox[]) - sum(heights)) + limits = cmap.colorrange + colors = lift(blockscene, cmap.mapping, cmap.color_mapping_type, cmap.color, cb.nsteps, limits; + ignore_equal_values=true) do mapping, mapping_type, values, n, limits + if mapping === nothing + if mapping_type === Makie.banded + error("Banded without a mapping is invalid. Please use colormap=cgrad(...; categorical=true)") + elseif mapping_type === Makie.categorical + return convert(Vector{Float64},1:length(unique(values))) + else + return convert(Vector{Float64}, LinRange(limits..., n)) + end else - max(1, width(framebox[]) - sum(heights)) + if mapping_type === Makie.categorical + # This is because cmap.mapping comes from cgrad.values, which doesn't encode categorical colormapping correctly + error("Mapping should not be used for categorical colormaps") + end + if mapping_type === Makie.continuous + # we need at least nsteps, to correctly sample from the colormap (which has the mapping applied already) + return convert(Vector{Float64}, LinRange(limits..., n)) + else + # Mapping is always 0..1, but color should be scaled + return limits[1] .+ (mapping .* (limits[2] - limits[1])) + end + return end end - barbox = lift(blockscene, barsize) do sz - fbox = framebox[] + lowclip_tri_visible = lift(x -> !(x isa Automatic), blockscene, cmap.lowclip; ignore_equal_values=true) + highclip_tri_visible = lift(x -> !(x isa Automatic), blockscene, cmap.highclip; ignore_equal_values=true) + tri_heights = lift(blockscene, highclip_tri_visible, lowclip_tri_visible, framebox; ignore_equal_values=true) do hv, lv, box if cb.vertical[] - BBox(left(fbox), right(fbox), bottom(fbox) + tri_heights[][1], top(fbox) - tri_heights[][2]) + return (lv * width(box), hv * width(box)) else - BBox(left(fbox) + tri_heights[][1], right(fbox) - tri_heights[][2], bottom(fbox), top(fbox)) - end + return (lv * height(box), hv * height(box)) + end .* sin(pi / 3) end - - map_is_categorical = lift(x -> x isa PlotUtils.CategoricalColorGradient, blockscene, cgradient) - - steps = lift(blockscene, cgradient, cb.nsteps) do cgradient, n - s = if cgradient isa PlotUtils.CategoricalColorGradient - cgradient.values + barbox = lift(blockscene, framebox; ignore_equal_values=true) do fbox + if cb.vertical[] + return BBox(left(fbox), right(fbox), bottom(fbox) + tri_heights[][1], top(fbox) - tri_heights[][2]) else - collect(LinRange(0, 1, n)) - end::Vector{Float64} + return BBox(left(fbox) + tri_heights[][1], right(fbox) - tri_heights[][2], bottom(fbox), top(fbox)) + end end - # it's hard to draw categorical and continous colormaps well - # with the same primitives + xrange = Observable(Float32[]; ignore_equal_values=true) + yrange = Observable(Float32[]; ignore_equal_values=true) - # therefore we make one interpolated image for continous - # colormaps and number of polys for categorical colormaps - # at the same time, then we just set one of them invisible - # depending on the type of colormap - # this should solve most white-line issues - - # for categorical colormaps we make a number of rectangle polys - - rects_and_colors = lift(blockscene, barbox, cb.vertical, steps, cgradient, cb.scale, - limits) do bbox, v, steps, gradient, scale, lims - - # we need to convert the 0 to 1 steps into rescaled 0 to 1 steps given the - # colormap's `scale` attribute - - s_scaled = scaled_steps(steps, scale, lims) - - xmin, ymin = minimum(bbox) - xmax, ymax = maximum(bbox) - - rects = if v - yvals = s_scaled .* (ymax - ymin) .+ ymin - [BBox(xmin, xmax, b, t) - for (b, t) in zip(yvals[1:end-1], yvals[2:end])] + function update_xyrange(bb, v, colors, scale, mapping_type) + xmin, ymin = minimum(bb) + xmax, ymax = maximum(bb) + if mapping_type == Makie.categorical + colors = edges(colors) + end + s_scaled = scale.(colors) + mini, maxi = extrema(s_scaled) + s_scaled = (s_scaled .- mini) ./ (maxi - mini) + if v + xrange[] = LinRange(xmin, xmax, 2) + yrange[] = s_scaled .* (ymax - ymin) .+ ymin else - xvals = s_scaled .* (xmax - xmin) .+ xmin - [BBox(l, r, ymin, ymax) - for (l, r) in zip(xvals[1:end-1], xvals[2:end])] + xrange[] = s_scaled .* (xmax - xmin) .+ xmin + yrange[] = LinRange(ymin, ymax, 2) end - - colors = get.(Ref(gradient), (steps[1:end-1] .+ steps[2:end]) ./2) - rects, colors + return end - colors = lift(x -> getindex(x, 2), blockscene, rects_and_colors) - rects = poly!(blockscene, - lift(x -> getindex(x, 1), blockscene, rects_and_colors); - color = colors, - visible = map_is_categorical, - inspectable = false - ) + update_xyrange(barbox[], cb.vertical[], colors[], cmap.scale[], cmap.color_mapping_type[]) + onany(update_xyrange, blockscene, barbox, cb.vertical, colors, cmap.scale, cmap.color_mapping_type) # for continous colormaps we sample a 1d image # to avoid white lines when rendering vector graphics - - continous_pixels = lift(blockscene, cb.vertical, cb.nsteps, cgradient, limits, - cb.scale) do v, n, grad, lims, scale - - s_steps = scaled_steps(LinRange(0, 1, n), scale, lims) - px = get.(Ref(grad), s_steps) - return v ? reshape(px, 1, n) : reshape(px, n, 1) + continous_pixels = lift(blockscene, cb.vertical, colors, + cmap.color_mapping_type) do v, colors, mapping_type + if mapping_type !== Makie.categorical + colors = (colors[1:end-1] .+ colors[2:end]) ./2 + end + n = length(colors) + return v ? reshape((colors), 1, n) : reshape((colors), n, 1) end - - cont_image = image!(blockscene, - lift(bb -> range(left(bb), right(bb); length=2), blockscene, barbox), - lift(bb -> range(bottom(bb), top(bb); length=2), blockscene, barbox), - continous_pixels, - visible=lift(!, blockscene, map_is_categorical), - interpolate = true, + # TODO, implement interpolate = true for irregular grics in CairoMakie + # Then, we can just use heatmap! and don't need the image plot! + show_cats = Observable(false; ignore_equal_values=true) + show_continous = Observable(false; ignore_equal_values=true) + on(blockscene, cmap.color_mapping_type; update=true) do type + if type === continuous + show_continous[] = true + show_cats[] = false + else + show_continous[] = false + show_cats[] = true + end + end + heatmap!(blockscene, + xrange, yrange, continous_pixels; + colormap=colormap, + visible=show_cats, + inspectable=false + ) + image!(blockscene, + lift(extrema, xrange), lift(extrema, yrange), continous_pixels; + colormap = colormap, + visible = show_continous, inspectable = false ) - highclip_tri = lift(blockscene, barbox, cb.spinewidth) do box, spinewidth if cb.vertical[] lb, rb = topline(box) @@ -237,11 +280,11 @@ function initialize_block!(cb::Colorbar) end end - highclip_tri_color = lift(blockscene, cb.highclip) do hc - to_color(isnothing(hc) ? :transparent : hc) + highclip_tri_color = lift(blockscene, cmap.highclip) do hc + to_color(hc isa Automatic || isnothing(hc) ? :transparent : hc) end - highclip_tri_poly = poly!(blockscene, highclip_tri, color = highclip_tri_color, + poly!(blockscene, highclip_tri, color = highclip_tri_color, strokecolor = :transparent, visible = highclip_tri_visible, inspectable = false) @@ -259,11 +302,11 @@ function initialize_block!(cb::Colorbar) end end - lowclip_tri_color = lift(blockscene, cb.lowclip) do lc - to_color(isnothing(lc) ? :transparent : lc) + lowclip_tri_color = lift(blockscene, cmap.lowclip) do lc + to_color(lc isa Automatic || isnothing(lc) ? :transparent : lc) end - lowclip_tri_poly = poly!(blockscene, lowclip_tri, color = lowclip_tri_color, + poly!(blockscene, lowclip_tri, color = lowclip_tri_color, strokecolor = :transparent, visible = lowclip_tri_visible, inspectable = false) @@ -314,11 +357,22 @@ function initialize_block!(cb::Colorbar) end + ticks = Observable{Any}() + map!(ticks, colors, cmap.color_mapping_type, cb.ticks) do cs, type, ticks + # For categorical we just enumerate + type === Makie.categorical ? (1:length(cs), string.(cs)) : ticks + end + + lims = lift(colors, cmap.color_mapping_type, limits) do cs, type, limits + return type === Makie.categorical ? (0.5, length(cs) + 0.5) : limits + end + axis = LineAxis(blockscene, endpoints = axispoints, flipped = cb.flipaxis, - limits = limits, ticklabelalign = cb.ticklabelalign, label = cb.label, + limits=lims, ticklabelalign=cb.ticklabelalign, label=cb.label, labelpadding = cb.labelpadding, labelvisible = cb.labelvisible, labelsize = cb.labelsize, labelcolor = cb.labelcolor, labelrotation = cb.labelrotation, - labelfont = cb.labelfont, ticklabelfont = cb.ticklabelfont, ticks = cb.ticks, tickformat = cb.tickformat, + labelfont=cb.labelfont, ticklabelfont=cb.ticklabelfont, ticks=ticks, + tickformat=cb.tickformat, ticklabelsize = cb.ticklabelsize, ticklabelsvisible = cb.ticklabelsvisible, ticksize = cb.ticksize, ticksvisible = cb.ticksvisible, ticklabelpad = cb.ticklabelpad, tickalign = cb.tickalign, ticklabelrotation = cb.ticklabelrotation, @@ -327,15 +381,13 @@ function initialize_block!(cb::Colorbar) spinecolor = :transparent, spinevisible = :false, flip_vertical_label = cb.flip_vertical_label, minorticksvisible = cb.minorticksvisible, minortickalign = cb.minortickalign, minorticksize = cb.minorticksize, minortickwidth = cb.minortickwidth, - minortickcolor = cb.minortickcolor, minorticks = cb.minorticks, scale = cb.scale) + minortickcolor = cb.minortickcolor, minorticks = cb.minorticks, scale = cmap.scale) cb.axis = axis - onany(blockscene, axis.protrusion, cb.vertical, cb.flipaxis) do axprotrusion, vertical, flipaxis - left, right, top, bottom = 0f0, 0f0, 0f0, 0f0 if vertical @@ -357,9 +409,17 @@ function initialize_block!(cb::Colorbar) # trigger protrusions with one of the attributes notify(cb.vertical) - + # We set everything via the ColorMapping now. To be backwards compatible, we always set those fields: + if (cb.colormap[] isa ColorMapping) + setfield!(cb, :limits, convert(Observable{Any}, limits)) + setfield!(cb, :colormap, convert(Observable{Any}, cmap.colormap)) + setfield!(cb, :highclip, convert(Observable{Any}, cmap.highclip)) + setfield!(cb, :lowclip, convert(Observable{Any}, cmap.lowclip)) + setfield!(cb, :scale, convert(Observable{Any}, cmap.scale)) + end # trigger bbox notify(cb.layoutobservables.suggestedbbox) + notify(barbox) return end @@ -369,17 +429,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]) + return @. (steps_lim_scaled - steps_lim_scaled[begin]) / (steps_lim_scaled[end] - steps_lim_scaled[begin]) end diff --git a/src/makielayout/blocks/intervalslider.jl b/src/makielayout/blocks/intervalslider.jl index d5e850ac059..01c456ca210 100644 --- a/src/makielayout/blocks/intervalslider.jl +++ b/src/makielayout/blocks/intervalslider.jl @@ -88,7 +88,7 @@ function initialize_block!(isl::IntervalSlider) end endbuttons = scatter!(blockscene, endpoints, color = endbuttoncolors, - markersize = isl.linewidth, strokewidth = 0, inspectable = false) + markersize = isl.linewidth, strokewidth = 0, inspectable = false, marker = Circle) linesegs = linesegments!(blockscene, linepoints, color = linecolors, linewidth = isl.linewidth, inspectable = false) @@ -107,7 +107,7 @@ function initialize_block!(isl::IntervalSlider) end buttonsizes = @lift($(isl.linewidth) .* $button_magnifications) buttons = scatter!(blockscene, middlepoints, color = isl.color_active, strokewidth = 0, - markersize = buttonsizes, inspectable = false) + markersize = buttonsizes, inspectable = false, marker = Circle) mouseevents = addmouseevents!(blockscene, isl.layoutobservables.computedbbox) diff --git a/src/makielayout/blocks/label.jl b/src/makielayout/blocks/label.jl index a755cc54e05..adc7de7fbc7 100644 --- a/src/makielayout/blocks/label.jl +++ b/src/makielayout/blocks/label.jl @@ -11,12 +11,13 @@ function initialize_block!(l::Label) t = text!( topscene, textpos, text = l.text, fontsize = l.fontsize, font = l.font, color = l.color, visible = l.visible, align = (:center, :center), rotation = l.rotation, markerspace = :data, - justification = l.justification, lineheight = l.lineheight, word_wrap_width = word_wrap_width, + justification = l.justification, lineheight = l.lineheight, word_wrap_width = word_wrap_width, inspectable = false) textbb = Ref(BBox(0, 1, 0, 1)) - onany(l.text, l.fontsize, l.font, l.rotation, word_wrap_width, l.padding) do _, _, _, _, _, padding + onany(topscene, l.text, l.fontsize, l.font, l.rotation, word_wrap_width, + l.padding) do _, _, _, _, _, padding textbb[] = Rect2f(boundingbox(t)) autowidth = width(textbb[]) + padding[1] + padding[2] autoheight = height(textbb[]) + padding[3] + padding[4] @@ -28,7 +29,7 @@ function initialize_block!(l::Label) return end - onany(layoutobservables.computedbbox, l.padding) do bbox, padding + onany(topscene, layoutobservables.computedbbox, l.padding) do bbox, padding if l.word_wrap[] tw = width(bbox) - padding[1] - padding[2] else diff --git a/src/makielayout/blocks/legend.jl b/src/makielayout/blocks/legend.jl index d711ce48338..53f0b05805b 100644 --- a/src/makielayout/blocks/legend.jl +++ b/src/makielayout/blocks/legend.jl @@ -1,6 +1,5 @@ -function initialize_block!(leg::Legend, - entry_groups::Observable{Vector{Tuple{Any, Vector{LegendEntry}}}}) - +function initialize_block!(leg::Legend; entrygroups) + entry_groups = convert(Observable{Vector{Tuple{Any,Vector{LegendEntry}}}}, entrygroups) blockscene = leg.blockscene # by default, `tellwidth = true` and `tellheight = false` for vertical legends @@ -12,16 +11,23 @@ function initialize_block!(leg::Legend, legend_area = lift(round_to_IRect2D, blockscene, leg.layoutobservables.computedbbox) - scene = Scene(blockscene, blockscene.px_area, camera = campixel!) + scene = Scene(blockscene, blockscene.viewport, camera = campixel!) # the rectangle in which the legend is drawn when margins are removed legendrect = lift(blockscene, legend_area, leg.margin) do la, lm enlarge(la, -lm[1], -lm[2], -lm[3], -lm[4]) end + backgroundcolor = if !isnothing(leg.bgcolor[]) + @warn("Keyword argument `bgcolor` is deprecated, use `backgroundcolor` instead.") + leg.bgcolor + else + leg.backgroundcolor + end + bg = poly!(scene, legendrect, - color = leg.bgcolor, strokewidth = leg.framewidth, visible = leg.framevisible, + color = backgroundcolor, strokewidth = leg.framewidth, visible = leg.framevisible, strokecolor = leg.framecolor, inspectable = false) translate!(bg, 0, 0, -7) # bg behind patches but before content at 0 (legend is at +10) @@ -257,16 +263,19 @@ function connect_block_layoutobservables!(leg::Legend, layout_width, layout_heig end + function legendelement_plots!(scene, element::MarkerElement, bbox::Observable{Rect2f}, defaultattrs::Attributes) merge!(element.attributes, defaultattrs) attrs = element.attributes - fracpoints = attrs.markerpoints points = lift((bb, fp) -> fractionpoint.(Ref(bb), fp), scene, bbox, fracpoints) scat = scatter!(scene, points, color = attrs.markercolor, marker = attrs.marker, markersize = attrs.markersize, strokewidth = attrs.markerstrokewidth, - strokecolor = attrs.markerstrokecolor, inspectable = false) + strokecolor = attrs.markerstrokecolor, inspectable = false, + colormap = attrs.markercolormap, + colorrange = attrs.markercolorrange, + ) return [scat] end @@ -278,6 +287,7 @@ function legendelement_plots!(scene, element::LineElement, bbox::Observable{Rect fracpoints = attrs.linepoints points = lift((bb, fp) -> fractionpoint.(Ref(bb), fp), scene, bbox, fracpoints) lin = lines!(scene, points, linewidth = attrs.linewidth, color = attrs.linecolor, + colormap = attrs.linecolormap, colorrange = attrs.linecolorrange, linestyle = attrs.linestyle, inspectable = false) return [lin] @@ -289,7 +299,8 @@ function legendelement_plots!(scene, element::PolyElement, bbox::Observable{Rect fracpoints = attrs.polypoints points = lift((bb, fp) -> fractionpoint.(Ref(bb), fp), scene, bbox, fracpoints) pol = poly!(scene, points, strokewidth = attrs.polystrokewidth, color = attrs.polycolor, - strokecolor = attrs.polystrokecolor, inspectable = false) + strokecolor = attrs.polystrokecolor, inspectable = false, + colormap = attrs.polycolormap, colorrange = attrs.polycolorrange) return [pol] end @@ -362,18 +373,24 @@ end _renaming_mapping(::Type{LineElement}) = Dict( :points => :linepoints, :color => :linecolor, + :colormap => :linecolormap, + :colorrange => :linecolorrange, ) _renaming_mapping(::Type{MarkerElement}) = Dict( :points => :markerpoints, :color => :markercolor, :strokewidth => :markerstrokewidth, :strokecolor => :markerstrokecolor, + :colormap => :markercolormap, + :colorrange => :markercolorrange, ) _renaming_mapping(::Type{PolyElement}) = Dict( :points => :polypoints, :color => :polycolor, :strokewidth => :polystrokewidth, :strokecolor => :polystrokecolor, + :colormap => :polycolormap, + :colorrange => :polycolorrange, ) function _rename_attributes!(T, a) @@ -390,44 +407,59 @@ function _rename_attributes!(T, a) a end +choose_scalar(attr, default) = is_scalar_attribute(to_value(attr)) ? attr : default -function scalar_lift(plot, attr, default) - observable = Observable{Any}() - map!(plot, observable, attr, default) do at, def - Makie.is_scalar_attribute(at) ? at : def - end - return observable +function extract_color(@nospecialize(plot), color_default) + color = haskey(plot, :calculated_color) ? plot.calculated_color : plot.color + color[] isa ColorMapping && return color_default + return choose_scalar(color, color_default) end function legendelements(plot::Union{Lines, LineSegments}, legend) LegendElement[LineElement( - color = scalar_lift(plot, plot.color, legend.linecolor), - linestyle = scalar_lift(plot, plot.linestyle, legend.linestyle), - linewidth = scalar_lift(plot, plot.linewidth, legend.linewidth))] + color = extract_color(plot, legend.linecolor), + linestyle = choose_scalar(plot.linestyle, legend.linestyle), + linewidth = choose_scalar(plot.linewidth, legend.linewidth), + colormap = plot.colormap, + colorrange = plot.colorrange, + )] end - function legendelements(plot::Scatter, legend) LegendElement[MarkerElement( - color = scalar_lift(plot, plot.color, legend.markercolor), - marker = scalar_lift(plot, plot.marker, legend.marker), - markersize = scalar_lift(plot, plot.markersize, legend.markersize), - strokewidth = scalar_lift(plot, plot.strokewidth, legend.markerstrokewidth), - strokecolor = scalar_lift(plot, plot.strokecolor, legend.markerstrokecolor), + color = extract_color(plot, legend.markercolor), + marker = choose_scalar(plot.marker, legend.marker), + markersize = choose_scalar(plot.markersize, legend.markersize), + strokewidth = choose_scalar(plot.strokewidth, legend.markerstrokewidth), + strokecolor = choose_scalar(plot.strokecolor, legend.markerstrokecolor), + colormap = plot.colormap, + colorrange = plot.colorrange, )] end function legendelements(plot::Union{Poly, Violin, BoxPlot, CrossBar, Density}, legend) + color = extract_color(plot, legend.polycolor) LegendElement[PolyElement( - color = scalar_lift(plot, plot.color, legend.polycolor), - strokecolor = scalar_lift(plot, plot.strokecolor, legend.polystrokecolor), - strokewidth = scalar_lift(plot, plot.strokewidth, legend.polystrokewidth), + color = color, + strokecolor = choose_scalar(plot.strokecolor, legend.polystrokecolor), + strokewidth = choose_scalar(plot.strokewidth, legend.polystrokewidth), + colormap = plot.colormap, + colorrange = plot.colorrange, )] end function legendelements(plot::Band, legend) # there seems to be no stroke for Band, so we set it invisible - LegendElement[PolyElement(polycolor = scalar_lift(plot, plot.color, legend.polystrokecolor), polystrokecolor = :transparent, polystrokewidth = 0)] + return LegendElement[PolyElement(; + polycolor = choose_scalar( + plot.color, + legend.polystrokecolor + ), + polystrokecolor = :transparent, + polystrokewidth = 0, + polycolormap = plot.colormap, + polycolorrange = plot.colorrange, + )] end # if there is no specific overload available, we go through the child plots and just stack @@ -462,7 +494,24 @@ function Base.propertynames(legendelement::T) where T <: LegendElement [fieldnames(T)..., keys(legendelement.attributes)...] end +function to_entry_group(legend_defaults, contents::AbstractVector, labels::AbstractVector, title=nothing) + if length(contents) != length(labels) + error("Number of elements not equal: $(length(contents)) content elements and $(length(labels)) labels.") + end + entries = [LegendEntry(label, content, legend_defaults) for (content, label) in zip(contents, labels)] + return [(title, entries)] +end +function to_entry_group( + legend_defaults, contentgroups::AbstractVector{<:AbstractVector}, + labelgroups::AbstractVector{<:AbstractVector}, titles::AbstractVector) + if !(length(titles) == length(contentgroups) == length(labelgroups)) + error("Number of elements not equal: $(length(titles)) titles, $(length(contentgroups)) content groups and $(length(labelgroups)) label groups.") + end + entries = [[LegendEntry(l, pg, legend_defaults) for (l, pg) in zip(labelgroup, contentgroup)] + for (labelgroup, contentgroup) in zip(labelgroups, contentgroups)] + return [(t, en) for (t, en) in zip(titles, entries)] +end """ Legend( @@ -481,17 +530,15 @@ function Legend(fig_or_scene, contents::AbstractVector, labels::AbstractVector, title = nothing; - kwargs...) - - if length(contents) != length(labels) - error("Number of elements not equal: $(length(contents)) content elements and $(length(labels)) labels.") - end + bbox=nothing, kwargs...) - entrygroups = Observable{Vector{EntryGroup}}([]) - legend = Legend(fig_or_scene, entrygroups; kwargs...) - entries = [LegendEntry(label, content, legend) for (content, label) in zip(contents, labels)] - entrygroups[] = [(title, entries)] - legend + scene = get_topscene(fig_or_scene) + legend_defaults = block_defaults(:Legend, Dict{Symbol, Any}(kwargs), scene) + entry_groups = to_entry_group(Attributes(legend_defaults), contents, labels, title) + entrygroups = Observable(entry_groups) + legend_defaults[:entrygroups] = entrygroups + # Use low-level constructor to not calculate legend_defaults a second time + return _block(Legend, fig_or_scene, (), legend_defaults, bbox; kwdict_complete=true) end @@ -516,19 +563,14 @@ function Legend(fig_or_scene, contentgroups::AbstractVector{<:AbstractVector}, labelgroups::AbstractVector{<:AbstractVector}, titles::AbstractVector; - kwargs...) - - if !(length(titles) == length(contentgroups) == length(labelgroups)) - error("Number of elements not equal: $(length(titles)) titles, $(length(contentgroups)) content groups and $(length(labelgroups)) label groups.") - end + bbox=nothing, kwargs...) - - entrygroups = Observable{Vector{EntryGroup}}([]) - legend = Legend(fig_or_scene, entrygroups; kwargs...) - entries = [[LegendEntry(l, pg, legend) for (l, pg) in zip(labelgroup, contentgroup)] - for (labelgroup, contentgroup) in zip(labelgroups, contentgroups)] - entrygroups[] = [(t, en) for (t, en) in zip(titles, entries)] - legend + scene = get_scene(fig_or_scene) + legend_defaults = block_defaults(:Legend, Dict{Symbol,Any}(kwargs), scene) + entry_groups = to_entry_group(legend_defaults, contentgroups, labelgroups, titles) + entrygroups = Observable(entry_groups) + legend_defaults[:entrygroups] = entrygroups + return _block(Legend, fig_or_scene, (), legend_defaults, bbox; kwdict_complete=true) end @@ -610,8 +652,8 @@ to one occurrence. """ function axislegend(ax, args...; position = :rt, kwargs...) Legend(ax.parent, args...; - bbox = ax.scene.px_area, - margin = (10, 10, 10, 10), + bbox = ax.scene.viewport, + margin = (6, 6, 6, 6), legend_position_to_aligns(position)..., kwargs...) end diff --git a/src/makielayout/blocks/menu.jl b/src/makielayout/blocks/menu.jl index d114ccb022a..a88471351ff 100644 --- a/src/makielayout/blocks/menu.jl +++ b/src/makielayout/blocks/menu.jl @@ -59,7 +59,7 @@ function initialize_block!(m::Menu; default = 1) map!(blockscene, _direction, m.layoutobservables.computedbbox, m.direction) do bb, dir if dir == Makie.automatic - pxa = pixelarea(blockscene)[] + pxa = viewport(blockscene)[] bottomspace = abs(bottom(pxa) - bottom(bb)) topspace = abs(top(pxa) - top(bb)) # slight preference for down @@ -81,7 +81,7 @@ function initialize_block!(m::Menu; default = 1) left(bbox), right(bbox), d === :down ? max(0, bottom(bbox) - h) : top(bbox), - d === :down ? bottom(bbox) : min(top(bbox) + h, top(blockscene.px_area[])))) + d === :down ? bottom(bbox) : min(top(bbox) + h, top(blockscene.viewport[])))) end menuscene = Scene(blockscene, scenearea, camera = campixel!, clear=true) @@ -256,7 +256,7 @@ function initialize_block!(m::Menu; default = 1) m.is_open[] = !m.is_open[] if m.is_open[] t = translation(menuscene)[] - y_for_top_align = height(menuscene.px_area[]) - listheight[] + y_for_top_align = height(menuscene.viewport[]) - listheight[] translate!(menuscene, t[1], y_for_top_align, t[3]) end return Consume(true) @@ -295,7 +295,7 @@ function initialize_block!(m::Menu; default = 1) t = translation(menuscene)[] # Hack to differentiate mousewheel and trackpad scrolling step = m.scroll_speed[] * y - new_y = max(min(t[2] - step, 0), height(menuscene.px_area[]) - listheight[]) + new_y = max(min(t[2] - step, 0), height(menuscene.viewport[]) - listheight[]) translate!(menuscene, t[1], new_y, t[3]) return Consume(true) else @@ -330,7 +330,7 @@ function initialize_block!(m::Menu; default = 1) end dropdown_arrow = scatter!( blockscene, symbol_pos; - marker=lift(iso -> iso ? '▴' : '▾', blockscene, m.is_open), + marker=lift(iso -> iso ? :utriangle : :dtriangle, blockscene, m.is_open), markersize = m.dropdown_arrow_size, color = m.dropdown_arrow_color, strokecolor = :transparent, diff --git a/src/makielayout/blocks/polaraxis.jl b/src/makielayout/blocks/polaraxis.jl new file mode 100644 index 00000000000..34eed35ab2d --- /dev/null +++ b/src/makielayout/blocks/polaraxis.jl @@ -0,0 +1,938 @@ +################################################################################ +### Main Block Intialization +################################################################################ + +function initialize_block!(po::PolarAxis; palette=nothing) + # Setup Scenes + cb = po.layoutobservables.computedbbox + scenearea = map(po.blockscene, cb) do cb + return Rect(round.(Int, minimum(cb)), round.(Int, widths(cb))) + end + + po.scene = Scene( + po.blockscene, scenearea, backgroundcolor = po.backgroundcolor, clear = true + ) + map!(to_color, po.scene, po.scene.backgroundcolor, po.backgroundcolor) + + po.overlay = Scene( + po.scene, scenearea, clear = false, backgroundcolor = :transparent, + transformation = Transformation(po.scene, transform_func = identity) + ) + + if !isnothing(palette) + # Backwards compatibility for when palette was part of axis! + palette_attr = palette isa Attributes ? palette : Attributes(palette) + po.scene.theme.palette = palette_attr + end + + # Setup camera/limits and Polar transform + usable_fraction = setup_camera_matrices!(po) + + Observables.connect!( + po.scene.transformation.transform_func, + @lift(Polar($(po.target_theta_0), $(po.direction), $(po.target_r0), $(po.theta_as_x), $(po.clip_r))) + ) + Observables.connect!( + po.overlay.transformation.transform_func, + @lift(Polar($(po.target_theta_0), $(po.direction), 0.0, false)) + ) + + # Draw clip, grid lines, spine, ticks + rticklabelplot, thetaticklabelplot = draw_axis!(po) + + # 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, + 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.viewport + ) do _, _, _, rpad, _, _, _, tpad, area + + # get maximum size of tick label + # (each boundingbox represents a string without text.position applied) + max_widths = Vec2f(0) + 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 + + max_width, max_height = max_widths + + space_from_center = 0.5 .* widths(area) + space_for_ticks = 2max(rpad, tpad) .+ (max_width, max_height) + space_for_axis = space_from_center .- space_for_ticks + + # 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, + po.target_rlims, po.target_thetalims, po.target_theta_0, po.direction, + po.rticklabelsize, po.rticklabelpad, + po.thetaticklabelsize, po.thetaticklabelpad, + po.overlay.viewport, 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; + text = po.title, + font = po.titlefont, + fontsize = po.titlesize, + color = po.titlecolor, + align = @lift(($(po.titlealign), :bottom)), + visible = po.titlevisible, + ) + 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 reserve + # space for the title + protrusions = map( + 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) + + return +end + + +################################################################################ +### 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)) + setfield!(po, :target_r0, Observable{Float32}(po.radius_at_origin[] isa Real ? po.radius_at_origin[] : 0f0)) + reset_limits!(po) + onany((_, _) -> reset_limits!(po), po.blockscene, po.rlimits, po.thetalimits) + + # get cartesian bbox defined by axis limits + data_bbox = map( + polaraxis_bbox, + po.blockscene, + po.target_rlims, po.target_thetalims, + po.target_r0, po.direction, po.target_theta_0 + ) + + # 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 + # OPT: data_bbox triggers on target_r0, target_rlims updates + onany(po.blockscene, usable_fraction, data_bbox) do usable_fraction, bb + mini = minimum(bb); ws = widths(bb) + rmax = po.target_rlims[][2] - po.target_r0[] + 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.viewport) 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.viewport) 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) + 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 +end + +function reset_limits!(po::PolarAxis) + # Resolve automatic as origin + rmin_to_origin = po.rlimits[][1] === :origin + rlimits = ifelse.(rmin_to_origin, nothing, po.rlimits[]) + + # at least one derived limit + if any(isnothing, 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 + + # Determine automatic target_r0 + if po.radius_at_origin[] isa Real + po.target_r0[] = po.radius_at_origin[] + else + po.target_r0[] = min(0.0, rmin) + end + + # cleanup autolimits (0 width, rmin ≥ target_r0) + if rmin == rmax + if rmin_to_origin + rmin = po.target_r0[] + else + rmin = max(po.target_r0[], rmin - 5.0) + end + rmax = rmin + 10.0 + else + dr = rmax - rmin + if rmin_to_origin + rmin = po.target_r0[] + else + rmin = max(po.target_r0[], rmin - po.rautolimitmargin[][1] * dr) + end + 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.(rlimits), (rmin, rmax), rlimits) + po.target_thetalims[] = ifelse.(isnothing.(po.thetalimits[]), (thetamin, thetamax), po.thetalimits[]) + else # all limits set + if po.target_rlims[] != rlimits + po.target_rlims[] = 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) + rtick_pos_lbl = Observable{Vector{<:Tuple{AbstractString, Point2f}}}() + 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 + + onany( + po.blockscene, + po.rticks, po.rminorticks, po.rtickformat, po.rtickangle, + po.direction, po.target_rlims, po.target_thetalims, po.sample_density, po.target_r0 + ) do rticks, rminorticks, rtickformat, rtickangle, + dir, rlims, thetalims, sample_density, target_r0 + + # For text: + rmaxinv = 1.0 / (rlims[2] - target_r0) + _rtickvalues, _rticklabels = get_ticks(rticks, identity, rtickformat, rlims...) + _rtickradius = (_rtickvalues .- target_r0) .* rmaxinv + _rtickangle = default_rtickangle(rtickangle, dir, thetalims) + rtick_pos_lbl[] = tuple.(_rticklabels, Point2f.(_rtickradius, _rtickangle)) + + # For grid lines + thetas = LinRange(thetalims..., sample_density) + rgridpoints[] = GeometryBasics.LineString.([Point2f.(r, thetas) for r in _rtickradius]) + + _rminortickvalues = get_minor_tickvalues(rminorticks, identity, _rtickvalues, rlims...) + _rminortickvalues .= (_rminortickvalues .- target_r0) .* 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 = trunc(Int, 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{Any, 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.target_theta_0, po.target_rlims, po.target_thetalims, po.target_r0 + ) do thetaticks, thetaminorticks, thetatickformat, px_pad, dir, theta_0, rlims, thetalims, r0 + + _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π + # This is a special case, since it's the only possible instance of colocation + if (_thetatickvalues[end] - _thetatickvalues[begin]) == 2π + pop!(_thetatickvalues) + pop!(_thetaticklabels) + end + + # 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, Point2f.(1, _thetatickvalues)) + + # Grid lines + rmin = (rlims[1] - r0) / (rlims[2] - r0) + thetagridpoints[] = [Point2f(r, theta) for theta in _thetatickvalues for r in (rmin, 1)] + + _thetaminortickvalues = get_minor_tickvalues(thetaminorticks, identity, _thetatickvalues, thetalims...) + thetaminorgridpoints[] = [Point2f(r, theta) for theta in _thetaminortickvalues for r in (rmin, 1)] + + return + end + + notify(po.target_thetalims) + + # plot using the created observables + + # major grids + rgridplot = lines!( + po.overlay, rgridpoints; + color = po.rgridcolor, + linestyle = po.rgridstyle, + linewidth = po.rgridwidth, + visible = po.rgridvisible, + ) + + thetagridplot = linesegments!( + po.overlay, thetagridpoints; + color = po.thetagridcolor, + linestyle = po.thetagridstyle, + linewidth = po.thetagridwidth, + visible = po.thetagridvisible, + ) + # minor grids + rminorgridplot = lines!( + po.overlay, rminorgridpoints; + color = po.rminorgridcolor, + linestyle = po.rminorgridstyle, + linewidth = po.rminorgridwidth, + visible = po.rminorgridvisible, + ) + + thetaminorgridplot = linesegments!( + po.overlay, thetaminorgridpoints; + color = po.thetaminorgridcolor, + linestyle = po.thetaminorgridstyle, + linewidth = po.thetaminorgridwidth, + visible = po.thetaminorgridvisible, + ) + + # tick labels + + 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 : to_color(sc) + end + + rticklabelplot = text!( + po.overlay, rtick_pos_lbl; + fontsize = po.rticklabelsize, + font = po.rticklabelfont, + color = po.rticklabelcolor, + strokewidth = po.rticklabelstrokewidth, + strokecolor = rstrokecolor, + 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 : to_color(sc) + end + + thetaticklabelplot = text!( + po.overlay, thetatick_pos_lbl; + fontsize = po.thetaticklabelsize, + font = po.thetaticklabelfont, + color = po.thetaticklabelcolor, + strokewidth = po.thetaticklabelstrokewidth, + strokecolor = thetastrokecolor, + align = thetatick_align[], + visible = po.thetaticklabelsvisible + ) + thetaticklabelplot.plots[1].plots[1].offset = thetatick_offset + + # Hack to deal with synchronous update problems + on(po.blockscene, thetatick_align) do align + thetaticklabelplot.align.val = align + if length(align) == length(thetatick_pos_lbl[]) + notify(thetaticklabelplot.align) + end + return + end + + # Clipping + + # 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, + outer_clip, + color = clipcolor, + visible = po.clip, + fxaa = false, + transformation = Transformation(), # no polar transform for this + shading = NoShading + ) + + # 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 + inner_clip_plot = poly!( + po.overlay, + inner_clip, + color = clipcolor, + visible = po.clip, + fxaa = false, + transformation = Transformation(), + shading = NoShading + ) + + # 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 + + onany(po.blockscene, po.target_rlims, po.target_r0) do lims, r0 + s = (lims[1] - r0) / (lims[2] - r0) + scale!(inner_clip_plot, Vec3f(s, s, 1)) + end + + notify(po.target_r0) + + # spine traces circle sector - inner circle + spine_points = map(po.blockscene, + po.target_rlims, po.target_thetalims, po.target_r0, po.sample_density + ) do (rmin, rmax), thetalims, r0, N + thetamin, thetamax = thetalims + rmin = (rmin - r0) / (rmax - r0) + 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 + ) + + notify(po.target_thetalims) + + 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 rticklabelplot, thetaticklabelplot +end + +function update_state_before_display!(ax::PolarAxis) + reset_limits!(ax) + return +end + +delete!(ax::PolarAxis, p::AbstractPlot) = delete!(ax.scene, p) + +################################################################################ +### Utilities +################################################################################ + + +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 + +""" + 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 + +function tightlimits!(po::PolarAxis) + po.rautolimitmargin = (0, 0) + po.thetaautolimitmargin = (0, 0) + reset_limits!(po) +end + + +""" + rlims!(ax::PolarAxis[, rmin], rmax) + +Sets the radial limits of a given `PolarAxis`. +""" +rlims!(po::PolarAxis, r::Union{Symbol, Nothing, Real}) = rlims!(po, po.rlimits[][1], r) + +function rlims!(po::PolarAxis, rmin::Union{Symbol, Nothing, Real}, rmax::Union{Nothing, Real}) + po.rlimits[] = (rmin, rmax) + return +end + +""" + thetalims!(ax::PolarAxis, thetamin, thetamax) + +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/blocks/scene.jl b/src/makielayout/blocks/scene.jl index 8ecbad040c6..e5cac177b0f 100644 --- a/src/makielayout/blocks/scene.jl +++ b/src/makielayout/blocks/scene.jl @@ -1,20 +1,9 @@ -function Makie.plot!( - lscene::LScene, P::Makie.PlotFunc, - attributes::Makie.Attributes, args...; - kw_attributes...) - - plot = Makie.plot!(lscene.scene, P, attributes, args...; kw_attributes...) +function reset_limits!(lscene::LScene) notify(lscene.scene.theme.limits) center!(lscene.scene) - plot -end - -function Makie.plot!(P::Makie.PlotFunc, ls::LScene, args...; kw_attributes...) - attributes = Makie.Attributes(kw_attributes) - _disallow_keyword(:axis, attributes) - _disallow_keyword(:figure, attributes) - Makie.plot!(ls, P, attributes, args...) + return end +tightlimits!(::LScene) = nothing # TODO implement!? function initialize_block!(ls::LScene; scenekw = NamedTuple()) blockscene = ls.blockscene @@ -61,8 +50,6 @@ function Base.delete!(ax::LScene, plot::AbstractPlot) ax end -can_be_current_axis(ls::LScene) = true - Makie.cam2d!(ax::LScene; kwargs...) = Makie.cam2d!(ax.scene; kwargs...) Makie.campixel!(ax::LScene; kwargs...) = Makie.campixel!(ax.scene; kwargs...) Makie.cam_relative!(ax::LScene; kwargs...) = Makie.cam_relative!(ax.scene; kwargs...) diff --git a/src/makielayout/blocks/textbox.jl b/src/makielayout/blocks/textbox.jl index 761ae53cd28..f679c7a9222 100644 --- a/src/makielayout/blocks/textbox.jl +++ b/src/makielayout/blocks/textbox.jl @@ -99,7 +99,7 @@ function initialize_block!(tbox::Textbox) end end - cursor = linesegments!(scene, cursorpoints, color = tbox.cursorcolor, linewidth = 2, inspectable = false) + cursor = linesegments!(scene, cursorpoints, color = tbox.cursorcolor, linewidth = 1, inspectable = false) tbox.cursoranimtask = nothing diff --git a/src/makielayout/defaultattributes.jl b/src/makielayout/defaultattributes.jl index 92cbb1f2f23..76c1c8420a3 100644 --- a/src/makielayout/defaultattributes.jl +++ b/src/makielayout/defaultattributes.jl @@ -10,7 +10,31 @@ function inherit(::Nothing, attr::Symbol, default_value) default_value end -function default_attributes(::Type{LineAxis}) +inherit(scene, attr::NTuple{1, <: Symbol}, default_value) = inherit(scene, attr[begin], default_value) + + +function inherit(scene, attr::NTuple{N, <: Symbol}, default_value) where N + current_dict = scene.theme + for i in 1:(N-1) + if haskey(current_dict, attr[i]) + current_dict = current_dict[attr[i]] + else + break + end + end + + if haskey(current_dict, attr[N]) + return lift(identity, current_dict[attr[N]]) + else + return inherit(scene.parent, attr, default_value) + end +end + +function inherit(::Nothing, attr::NTuple{N, Symbol}, default_value::T) where {N, T} + default_value +end + +function generic_plot_attributes(::Type{LineAxis}) Attributes( endpoints = (Point2f(0, 0), Point2f(100, 0)), trimspine = false, @@ -52,9 +76,9 @@ end function attributenames(::Type{LegendEntry}) (:label, :labelsize, :labelfont, :labelcolor, :labelhalign, :labelvalign, :patchsize, :patchstrokecolor, :patchstrokewidth, :patchcolor, - :linepoints, :linewidth, :linecolor, :linestyle, - :markerpoints, :markersize, :markerstrokewidth, :markercolor, :markerstrokecolor, - :polypoints, :polystrokewidth, :polycolor, :polystrokecolor) + :linepoints, :linewidth, :linecolor, :linestyle, :linecolorrange, :linecolormap, + :markerpoints, :markersize, :markerstrokewidth, :markercolor, :markerstrokecolor, :markercolorrange, :markercolormap, + :polypoints, :polystrokewidth, :polycolor, :polystrokecolor, :polycolorrange, :polycolormap) end function extractattributes(attributes::Attributes, typ::Type) diff --git a/src/makielayout/helpers.jl b/src/makielayout/helpers.jl index b222d029c17..2732f804cc9 100644 --- a/src/makielayout/helpers.jl +++ b/src/makielayout/helpers.jl @@ -137,7 +137,9 @@ function tightlimits!(la::Axis, ::Top) autolimits!(la) end -GridLayoutBase.GridLayout(scene::Scene, args...; kwargs...) = GridLayout(args...; bbox = lift(x -> Rect2f(x), scene, pixelarea(scene)), kwargs...) +function GridLayoutBase.GridLayout(scene::Scene, args...; kwargs...) + return GridLayout(args...; bbox=lift(Rect2f, viewport(scene)), kwargs...) +end function axislines!(scene, rect, spinewidth, topspinevisible, rightspinevisible, leftspinevisible, bottomspinevisible, topspinecolor, leftspinecolor, diff --git a/src/makielayout/interactions.jl b/src/makielayout/interactions.jl index f94641d69b9..3945f0ba86e 100644 --- a/src/makielayout/interactions.jl +++ b/src/makielayout/interactions.jl @@ -123,7 +123,7 @@ end function _selection_vertices(ax_scene, outer, inner) _clamp(p, plow, phigh) = Point2f(clamp(p[1], plow[1], phigh[1]), clamp(p[2], plow[2], phigh[2])) - proj(point) = project(ax_scene, point) .+ minimum(ax_scene.px_area[]) + proj(point) = project(ax_scene, point) .+ minimum(ax_scene.viewport[]) transf = Makie.transform_func(ax_scene) outer = positivize(Makie.apply_transform(transf, outer)) inner = positivize(Makie.apply_transform(transf, inner)) @@ -180,7 +180,7 @@ function process_interaction(r::RectangleZoom, event::MouseEvent, ax::Axis) try r.callback(r.rectnode[]) catch e - @warn "error in rectangle zoom" exception=e + @warn "error in rectangle zoom" exception=(e, Base.catch_backtrace()) end r.active[] = false return Consume(true) @@ -211,7 +211,7 @@ function positivize(r::Rect2) return Rect2(newori, newwidths) end -function process_interaction(l::LimitReset, event::MouseEvent, ax::Axis) +function process_interaction(::LimitReset, event::MouseEvent, ax::Axis) if event.type === MouseEventTypes.leftclick if ispressed(ax.scene, Keyboard.left_control) @@ -242,7 +242,7 @@ function process_interaction(s::ScrollZoom, event::ScrollEvent, ax::Axis) cam = camera(scene) if zoom != 0 - pa = pixelarea(scene)[] + pa = viewport(scene)[] z = (1f0 - s.speed)^zoom @@ -302,11 +302,10 @@ function process_interaction(dp::DragPan, event::MouseEvent, ax) ypanlock = ax.ypanlock xpankey = ax.xpankey ypankey = ax.ypankey - panbutton = ax.panbutton scene = ax.scene cam = camera(scene) - pa = pixelarea(scene)[] + pa = viewport(scene)[] mp_axscene = Vec4f((event.px .- pa.origin)..., 0, 1) mp_axscene_prev = Vec4f((event.prev_px .- pa.origin)..., 0, 1) diff --git a/src/makielayout/lineaxis.jl b/src/makielayout/lineaxis.jl index 5bbb30d29be..f2eb13f60bd 100644 --- a/src/makielayout/lineaxis.jl +++ b/src/makielayout/lineaxis.jl @@ -1,5 +1,10 @@ +# the hyphen which is usually used to store negative number strings +# is shorter than the dedicated minus in most fonts, the minus glyph +# looks more balanced with numbers, especially in superscripts or subscripts +const MINUS_SIGN = "−" # == "\u2212" (Unicode minus) + function LineAxis(parent::Scene; @nospecialize(kwargs...)) - attrs = merge!(Attributes(kwargs), default_attributes(LineAxis)) + attrs = merge!(Attributes(kwargs), generic_plot_attributes(LineAxis)) return LineAxis(parent, attrs) end @@ -78,8 +83,8 @@ function create_linepoints( return [from, to] else x = position - pstart = Point2f(-0.5f0 * tickwidth, 0) - pend = Point2f(0.5f0 * tickwidth, 0) + pstart = Point2f(0, -0.5f0 * tickwidth) + pend = Point2f(0, 0.5f0 * tickwidth) from = trimspine[1] ? tickpositions[1] .+ pstart : Point2f(x, extents_oriented[1] - 0.5spine_width) to = trimspine[2] ? tickpositions[end] .+ pend : Point2f(x, extents_oriented[2] + 0.5spine_width) return [from, to] @@ -182,6 +187,10 @@ function update_tick_obs(tick_obs, horizontal::Observable{Bool}, flipped::Observ return end +# if labels are given manually, it's possible that some of them are outside the displayed limits +# we only check approximately because otherwise because of floating point errors, ticks can be dismissed sometimes +is_within_limits(tv, limits) = (limits[1] ≤ tv || limits[1] ≈ tv) && (tv ≤ limits[2] || tv ≈ limits[2]) + function update_tickpos_string(closure_args, tickvalues_labels_unfiltered, reversed::Bool, scale) tickstrings, tickpositions, tickvalues, pos_extents_horizontal, limits_obs = closure_args @@ -199,12 +208,7 @@ function update_tickpos_string(closure_args, tickvalues_labels_unfiltered, rever lim_o = limits[1] lim_w = limits[2] - limits[1] - # if labels are given manually, it's possible that some of them are outside the displayed limits - # we only check approximately because otherwise because of floating point errors, ticks can be dismissed sometimes - i_values_within_limits = findall(tickvalues_unfiltered) do tv - return (limits[1] <= tv || limits[1] ≈ tv) && - (tv <= limits[2] || tv ≈ limits[2]) - end + i_values_within_limits = findall(tv -> is_within_limits(tv, limits), tickvalues_unfiltered) tickvalues[] = tickvalues_unfiltered[i_values_within_limits] @@ -226,7 +230,7 @@ function update_tickpos_string(closure_args, tickvalues_labels_unfiltered, rever return end -function update_minor_ticks(minortickpositions, limits::NTuple{2, Float32}, pos_extents_horizontal, minortickvalues, scale, reversed::Bool) +function update_minor_ticks(minortickpositions, limits::NTuple{2, Float32}, pos_extents_horizontal, minortickvalues_unfiltered, scale, reversed::Bool) position::Float32, extents_uncorrected::NTuple{2, Float32}, horizontal::Bool = pos_extents_horizontal extents = reversed ? reverse(extents_uncorrected) : extents_uncorrected @@ -234,8 +238,7 @@ function update_minor_ticks(minortickpositions, limits::NTuple{2, Float32}, pos_ px_o = extents[1] px_width = extents[2] - extents[1] - lim_o = limits[1] - lim_w = limits[2] - limits[1] + minortickvalues = filter(tv -> is_within_limits(tv, limits), minortickvalues_unfiltered) tickvalues_scaled = scale.(minortickvalues) @@ -494,6 +497,7 @@ function LineAxis(parent::Scene, attrs::Attributes) # before other stuff is triggered by them, which accesses the # ticklabel boundingbox (which needs to be updated already) # so we move the new listener from text! to the front + pushfirst!(ticklabel_annotation_obs.listeners, pop!(ticklabel_annotation_obs.listeners)) # trigger calculation of ticklabel width once, now that it's not nothing anymore @@ -556,12 +560,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 +574,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 +589,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) @@ -603,9 +606,9 @@ function get_ticks(l::LogTicks, scale::Union{typeof(log10), typeof(log2), typeof xs -> Showoff.showoff(xs, :plain), ticks_scaled ) - labels = rich.(_logbase(scale), superscript.(labels_scaled, offset = Vec2f(0.1f0, 0f0))) + labels = rich.(_logbase(scale), superscript.(replace.(labels_scaled, "-" => MINUS_SIGN), offset = Vec2f(0.1f0, 0f0))) - (ticks, labels) + ticks, labels end # function get_ticks(::Automatic, scale::typeof(Makie.logit), any_formatter, vmin, vmax) @@ -615,27 +618,6 @@ end logit_10(x) = Makie.logit(x) / log(10) expit_10(x) = Makie.logistic(log(10) * x) -# function get_ticks(l::LogitTicks, scale::typeof(Makie.logit), ::Automatic, vmin, vmax) - -# ticks_scaled = get_tickvalues(l.linear_ticks, identity, logit_10(vmin), logit_10(vmax)) - -# ticks = expit_10.(ticks_scaled) - -# base_labels = get_ticklabels(automatic, ticks_scaled) - -# labels = map(ticks_scaled, base_labels) do t, bl -# if t == 0 -# "¹/₂" -# elseif t < 0 -# "10" * Makie.UnicodeFun.to_superscript(bl) -# else -# "1-10" * Makie.UnicodeFun.to_superscript("-" * bl) -# end -# end - -# (ticks, labels) -# end - """ get_tickvalues(lt::LinearTicks, vmin, vmax) @@ -666,9 +648,9 @@ end """ get_ticklabels(::Automatic, values) -Gets tick labels by applying `showoff` to `values`. +Gets tick labels by applying `showoff_minus` to `values`. """ -get_ticklabels(::Automatic, values) = Showoff.showoff(values) +get_ticklabels(::Automatic, values) = showoff_minus(values) """ get_ticklabels(formatfunction::Function, values) @@ -684,15 +666,53 @@ 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 multiples = Makie.get_tickvalues(LinearTicks(m.n_ideal), dvmin, dvmax) - multiples .* m.multiple, Showoff.showoff(multiples) .* m.suffix + multiples .* m.multiple, showoff_minus(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_minus(round.(multiples .* m.label_factor, sigdigits = sigdigits)) .* m.suffix +end + +# Replaces hyphens in negative numbers with the unicode MINUS_SIGN +function showoff_minus(x::AbstractVector) + # TODO: don't use the `replace` workaround + replace.(Showoff.showoff(x), r"-(?=\d)" => MINUS_SIGN) +end + +# identity or unsupported scales function get_minor_tickvalues(i::IntervalsBetween, scale, tickvalues, vmin, vmax) vals = Float64[] length(tickvalues) < 2 && return vals @@ -726,8 +746,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/makielayout/mousestatemachine.jl b/src/makielayout/mousestatemachine.jl index 0fdd41e3bfe..c7fe0ddadd1 100644 --- a/src/makielayout/mousestatemachine.jl +++ b/src/makielayout/mousestatemachine.jl @@ -3,27 +3,35 @@ module MouseEventTypes out enter over + leftdown rightdown middledown + leftup rightup middleup + leftdragstart rightdragstart middledragstart + leftdrag rightdrag middledrag + leftdragstop rightdragstop middledragstop + leftclick rightclick middleclick + leftdoubleclick rightdoubleclick middledoubleclick + downoutside end export MouseEventType @@ -122,6 +130,61 @@ function addmouseevents!(scene, bbox::Observables.AbstractObservable{<: Rect2}; end +# don't react to buttons beyond the first three +_isstandardmousebutton(b) = (b == Mouse.left || b == Mouse.middle || b == Mouse.right) + +# TODO +# Make these enums so we can just do `Mouse.left & Mouse.drag` + +function to_drag_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftdrag + b === Mouse.right && return MouseEventTypes.rightdrag + b === Mouse.middle && return MouseEventTypes.middledrag + error("No recognized mouse button $b") +end + +function to_drag_start_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftdragstart + b === Mouse.right && return MouseEventTypes.rightdragstart + b === Mouse.middle && return MouseEventTypes.middledragstart + return error("No recognized mouse button $b") +end + +function to_drag_stop_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftdragstop + b === Mouse.right && return MouseEventTypes.rightdragstop + b === Mouse.middle && return MouseEventTypes.middledragstop + return error("No recognized mouse button $b") +end + +function to_down_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftdown + b === Mouse.right && return MouseEventTypes.rightdown + b === Mouse.middle && return MouseEventTypes.middledown + return error("No recognized mouse button $b") +end + +function to_up_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftup + b === Mouse.right && return MouseEventTypes.rightup + b === Mouse.middle && return MouseEventTypes.middleup + return error("No recognized mouse button $b") +end + +function to_doubleclick_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftdoubleclick + b === Mouse.right && return MouseEventTypes.rightdoubleclick + b === Mouse.middle && return MouseEventTypes.middledoubleclick + return error("No recognized mouse button $b") +end + +function to_click_event(b::Mouse.Button) + b === Mouse.left && return MouseEventTypes.leftclick + b === Mouse.right && return MouseEventTypes.rightclick + b === Mouse.middle && return MouseEventTypes.middleclick + return error("No recognized mouse button $b") +end + function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) Mouse = Makie.Mouse dblclick_max_interval = 0.2 @@ -156,16 +219,11 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) # this can mean a new drag started, or a drag continues if it is ongoing. # it can also mean that a drag that started outside and isn't related to this # object is going across it and should be ignored here - if last_mouseevent[] == Mouse.press + if last_mouseevent[] == Mouse.press && _isstandardmousebutton(mouse_downed_button[]) if drag_ongoing[] # continue the drag - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftdrag - Mouse.right => MouseEventTypes.rightdrag - Mouse.middle => MouseEventTypes.middledrag - x => error("No recognized mouse button $x") - end + event = to_drag_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) @@ -175,23 +233,13 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) # that means a drag started if mouse_downed_inside[] drag_ongoing[] = true - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftdragstart - Mouse.right => MouseEventTypes.rightdragstart - Mouse.middle => MouseEventTypes.middledragstart - x => error("No recognized mouse button $x") - end + event = to_drag_start_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) consumed = consumed || x - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftdrag - Mouse.right => MouseEventTypes.rightdrag - Mouse.middle => MouseEventTypes.middledrag - x => error("No recognized mouse button $x") - end + event = to_drag_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) @@ -241,18 +289,13 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) # mouse went down, this can either happen inside or outside the objects of interest # we also only react if one button is pressed, because otherwise things go crazy (pressed left button plus clicks from other buttons in between are not allowed, e.g.) - if event.action == Mouse.press + if event.action == Mouse.press && _isstandardmousebutton(first(pressed_buttons)) if length(pressed_buttons) == 1 button = first(pressed_buttons) mouse_downed_button[] = button if mouse_was_inside[] - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftdown - Mouse.right => MouseEventTypes.rightdown - Mouse.middle => MouseEventTypes.middledown - x => error("No recognized mouse button $x") - end + event = to_down_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) @@ -267,7 +310,7 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) end end last_mouseevent[] = Mouse.press - elseif event.action == Mouse.release + elseif event.action == Mouse.release && _isstandardmousebutton(mouse_downed_button[]) # only register up events and clicks if the upped button matches # the recorded downed one # and it can't be nothing (if the first up event comes from outside) @@ -278,12 +321,7 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) if downed_button_missing_from_pressed && some_mouse_button_had_been_downed if drag_ongoing[] - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftdragstop - Mouse.right => MouseEventTypes.rightdragstop - Mouse.middle => MouseEventTypes.middledragstop - x => error("No recognized mouse button $x") - end + event = to_drag_stop_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) @@ -292,13 +330,7 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) if mouse_was_inside[] # up after drag done over element - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftup - Mouse.right => MouseEventTypes.rightup - Mouse.middle => MouseEventTypes.middleup - x => error("No recognized mouse button $x") - end - + event = to_up_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) @@ -320,24 +352,14 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) if dt_last_click < dblclick_max_interval && !last_click_was_double[] && mouse_downed_button[] == b_last_click[] - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftdoubleclick - Mouse.right => MouseEventTypes.rightdoubleclick - Mouse.middle => MouseEventTypes.middledoubleclick - x => error("No recognized mouse button $x") - end + event = to_doubleclick_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) consumed = consumed || x last_click_was_double[] = true else - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftclick - Mouse.right => MouseEventTypes.rightclick - Mouse.middle => MouseEventTypes.middleclick - x => error("No recognized mouse button $x") - end + event = to_click_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) @@ -350,13 +372,7 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) mouse_downed_inside[] = false # up after click - event = @match mouse_downed_button[] begin - Mouse.left => MouseEventTypes.leftup - Mouse.right => MouseEventTypes.rightup - Mouse.middle => MouseEventTypes.middleup - x => error("No recognized mouse button $x") - end - + event = to_up_event(mouse_downed_button[]) x = setindex!(mouseevent, MouseEvent(event, t, data, px, prev_t[], prev_data[], prev_px[]) ) diff --git a/src/makielayout/types.jl b/src/makielayout/types.jl index f0a22f98c5c..93cb5e4f5a3 100644 --- a/src/makielayout/types.jl +++ b/src/makielayout/types.jl @@ -8,14 +8,6 @@ end struct DataAspect end - -struct Cycler - counters::IdDict{Type, Int} -end - -Cycler() = Cycler(IdDict{Type, Int}()) - - struct Cycle cycle::Vector{Pair{Vector{Symbol}, Symbol}} covary::Bool @@ -82,6 +74,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) @@ -185,14 +197,12 @@ struct KeysEvent keys::Set{Makie.Keyboard.Button} end -@Block Axis begin +@Block Axis <: AbstractAxis begin scene::Scene xaxislinks::Vector{Axis} yaxislinks::Vector{Axis} targetlimits::Observable{Rect2f} finallimits::Observable{Rect2f} - cycler::Cycler - palette::Attributes block_limit_linking::Observable{Bool} mouseeventhandle::MouseEventHandle scrollevents::Observable{ScrollEvent} @@ -310,9 +320,9 @@ end "The horizontal and vertical alignment of the yticklabels." yticklabelalign::Union{Makie.Automatic, Tuple{Symbol, Symbol}} = Makie.automatic "The size of the xtick marks." - xticksize::Float64 = 6f0 + xticksize::Float64 = 5f0 "The size of the ytick marks." - yticksize::Float64 = 6f0 + yticksize::Float64 = 5f0 "Controls if the xtick marks are visible." xticksvisible::Bool = true "Controls if the ytick marks are visible." @@ -411,7 +421,7 @@ end so that the axis aspect ratio width/height matches `ratio`. Note that both `DataAspect` and `AxisAspect` can result in excess whitespace around the axis. - To make a `GridLayout` aware of aspect ratio constraints, refer to the `Aspect` column or row size setting. + To make a `GridLayout` aware of aspect ratio constraints, refer to the `Aspect` column or row size setting. """ aspect = nothing "The vertical alignment of the axis within its suggested bounding box." @@ -451,12 +461,12 @@ end xticks = Makie.automatic """ The formatter for the ticks on the x axis. - + Usually, the tick values are determined first using `Makie.get_tickvalues`, after which `Makie.get_ticklabels(xtickformat, xtickvalues)` is called. If there is a special method defined, tick values and labels can be determined together using `Makie.get_ticks` instead. Check the docstring for `xticks` for more information. - + Common objects that can be used for tick formatting are: - A `Function` that takes a vector of numbers and returns a vector of labels. A label can be anything that can be plotted by the `text` primitive. @@ -485,12 +495,12 @@ end yticks = Makie.automatic """ The formatter for the ticks on the y axis. - + Usually, the tick values are determined first using `Makie.get_tickvalues`, after which `Makie.get_ticklabels(ytickformat, ytickvalues)` is called. If there is a special method defined, tick values and labels can be determined together using `Makie.get_ticks` instead. Check the docstring for `yticks` for more information. - + Common objects that can be used for tick formatting are: - A `Function` that takes a vector of numbers and returns a vector of labels. A label can be anything that can be plotted by the `text` primitive. @@ -546,14 +556,14 @@ end The function `autolimits!` resets the value of `limits` to `(nothing, nothing)` and adjusts the axis limits according to the extents of the plots added to the axis. - - The value of `limits` can be a four-element tuple `(xlow, xhigh, ylow, high)` where each value + + The value of `limits` can be a four-element tuple `(xlow, xhigh, ylow, yhigh)` where each value can be a real number or `nothing`. It can also be a tuple `(x, y)` where `x` and `y` can be `nothing` or a tuple `(low, high)`. In all cases, `nothing` means that the respective limit values will be automatically determined. Automatically determined limits are also influenced by `xautolimitmargin` and `yautolimitmargin`. - + The convenience functions `xlims!` and `ylims!` allow to set only the x or y part of `limits`. The function `limits!` is another option to set both x and y simultaneously. """ @@ -569,7 +579,7 @@ end "The alignment of x minor ticks on the axis spine" xminortickalign::Float64 = 0f0 "The tick size of x minor ticks" - xminorticksize::Float64 = 4f0 + xminorticksize::Float64 = 3f0 "The tick width of x minor ticks" xminortickwidth::Float64 = 1f0 "The tick color of x minor ticks" @@ -588,7 +598,7 @@ end "The alignment of y minor ticks on the axis spine" yminortickalign::Float64 = 0f0 "The tick size of y minor ticks" - yminorticksize::Float64 = 4f0 + yminorticksize::Float64 = 3f0 "The tick width of y minor ticks" yminortickwidth::Float64 = 1f0 "The tick color of y minor ticks" @@ -609,14 +619,14 @@ end `identity`, `log`, `log2`, `log10`, `sqrt`, `logit`, `Makie.pseudolog10` and `Makie.Symlog10`. To use a custom function, you have to define appropriate methods for `Makie.inverse_transform`, `Makie.defaultlimits` and `Makie.defined_interval`. - + If the scaling function is only defined over a limited interval, no plot object may have a source datum that lies outside of that range. For example, there may be no x value lower than or equal to 0 when `log` is selected for `xscale`. What matters are the source data, not the user-selected limits, because all data have to be transformed, irrespective of whether they lie inside or outside of the current limits. - + The axis scale may affect tick finding and formatting, depending on the values of `xticks` and `xtickformat`. """ @@ -628,14 +638,14 @@ end `identity`, `log`, `log2`, `log10`, `sqrt`, `logit`, `Makie.pseudolog10` and `Makie.Symlog10`. To use a custom function, you have to define appropriate methods for `Makie.inverse_transform`, `Makie.defaultlimits` and `Makie.defined_interval`. - + If the scaling function is only defined over a limited interval, no plot object may have a source datum that lies outside of that range. For example, there may be no y value lower than or equal to 0 when `log` is selected for `yscale`. What matters are the source data, not the user-selected limits, because all data have to be transformed, irrespective of whether they lie inside or outside of the current limits. - + The axis scale may affect tick finding and formatting, depending on the values of `yticks` and `ytickformat`. """ @@ -651,7 +661,7 @@ function RectangleZoom(f::Function, ax::Axis; kw...) faces = [1 2 5; 5 2 6; 2 3 6; 6 3 7; 3 4 7; 7 4 8; 4 1 8; 8 1 5] # plot to blockscene, so ax.scene stays exclusive for user plots # That's also why we need to pass `ax.scene` to _selection_vertices, so it can project to that space - mesh = mesh!(ax.blockscene, selection_vertices, faces, color = (:black, 0.2), shading = false, + mesh = mesh!(ax.blockscene, selection_vertices, faces, color = (:black, 0.2), shading = NoShading, inspectable = false, visible=r.active, transparency=true) # translate forward so selection mesh and frame are never behind data translate!(mesh, 0, 0, 100) @@ -693,7 +703,7 @@ end "The color of the tick labels." ticklabelcolor = @inherit(:textcolor, :black) "The size of the tick marks." - ticksize = 6f0 + ticksize = 5f0 "Controls if the tick marks are visible." ticksvisible = true "The ticks." @@ -750,36 +760,40 @@ end tellwidth = true "Controls if the parent layout can adjust to this element's height" tellheight = true + "The colormap that the colorbar uses." colormap = @inherit(:colormap, :viridis) "The range of values depicted in the colorbar." limits = nothing "The range of values depicted in the colorbar." colorrange = nothing - "The align mode of the colorbar in its parent GridLayout." - alignmode = Inside() - "The number of steps in the heatmap underlying the colorbar gradient." - nsteps = 100 "The color of the high clip triangle." highclip = nothing "The color of the low clip triangle." lowclip = nothing + "The axis scale" + scale = identity + + + "The align mode of the colorbar in its parent GridLayout." + alignmode = Inside() + "The number of steps in the heatmap underlying the colorbar gradient." + nsteps = 100 + "Controls if minor ticks are visible" minorticksvisible = false "The alignment of minor ticks on the axis spine" minortickalign = 0f0 "The tick size of minor ticks" - minorticksize = 4f0 + minorticksize = 3f0 "The tick width of minor ticks" minortickwidth = 1f0 "The tick color of minor ticks" minortickcolor = :black "The tick locator for the minor ticks" minorticks = IntervalsBetween(5) - "The axis scale" - scale = identity "The width or height of the colorbar, depending on if it's vertical or horizontal, unless overridden by `width` / `height`" - size = 16 + size = 12 end end @@ -832,14 +846,16 @@ end valign = :center "The horizontal alignment of the rectangle in its suggested boundingbox" halign = :center - "The extra space added to the sides of the rectangle boundingbox." - padding = (0f0, 0f0, 0f0, 0f0) "The line width of the rectangle's border." strokewidth = 1f0 "Controls if the border of the rectangle is visible." strokevisible = true "The color of the border." strokecolor = RGBf(0, 0, 0) + "The linestyle of the rectangle border" + linestyle = nothing + "The radius of the rounded corner. One number is for all four corners, four numbers for going clockwise from top-right." + cornerradius = 0.0 "The width setting of the rectangle." width = nothing "The height setting of the rectangle." @@ -875,7 +891,7 @@ end "The current value of the slider. Don't set this manually, use the function `set_close_to!`." value = 0 "The width of the slider line" - linewidth::Float32 = 15 + linewidth::Float32 = 10 "The color of the slider when the mouse hovers over it." color_active_dimmed::RGBAf = COLOR_ACCENT_DIMMED[] "The color of the slider when the mouse clicks and drags the slider." @@ -939,7 +955,7 @@ end "The current interval of the slider. Don't set this manually, use the function `set_close_to!`." interval = (0, 0) "The width of the slider line" - linewidth::Float64 = 15.0 + linewidth::Float64 = 10.0 "The color of the slider when the mouse hovers over it." color_active_dimmed::RGBAf = COLOR_ACCENT_DIMMED[] "The color of the slider when the mouse clicks and drags the slider." @@ -962,7 +978,7 @@ end "The vertical alignment of the button in its suggested boundingbox" valign = :center "The extra space added to the sides of the button label's boundingbox." - padding = (10f0, 10f0, 10f0, 10f0) + padding = (8f0, 8f0, 8f0, 8f0) "The font size of the button label." fontsize = @inherit(:fontsize, 16f0) "The text of the button label." @@ -1011,9 +1027,9 @@ end "The vertical alignment of the toggle in its suggested bounding box." valign = :center "The width of the toggle." - width = 60 + width = 32 "The height of the toggle." - height = 28 + height = 18 "Controls if the parent layout can adjust to this element's width" tellwidth = true "Controls if the parent layout can adjust to this element's height" @@ -1075,13 +1091,13 @@ end "Color of the dropdown arrow" dropdown_arrow_color = (:black, 0.2) "Size of the dropdown arrow" - dropdown_arrow_size = 20 + dropdown_arrow_size = 10 "The list of options selectable in the menu. This can be any iterable of a mixture of strings and containers with one string and one other value. If an entry is just a string, that string is both label and selection. If an entry is a container with one string and one other value, the string is the label and the other value is the selection." options = ["no options"] "Font size of the cell texts" fontsize = @inherit(:fontsize, 16f0) "Padding of entry texts" - textpadding = (10, 10, 10, 10) + textpadding = (8, 10, 8, 8) "Color of entry texts" textcolor = :black "The opening direction of the menu (:up or :down)" @@ -1160,11 +1176,13 @@ const EntryGroup = Tuple{Any, Vector{LegendEntry}} "The vertical alignment of the entry labels." labelvalign = :center "The additional space between the legend content and the border." - padding = (10f0, 10f0, 8f0, 8f0) + padding = (6f0, 6f0, 6f0, 6f0) "The additional space between the legend and its suggested boundingbox." margin = (0f0, 0f0, 0f0, 0f0) + "The background color of the legend. DEPRECATED - use `backgroundcolor` instead." + bgcolor = nothing "The background color of the legend." - bgcolor = :white + backgroundcolor = :white "The color of the legend border." framecolor = :black "The line width of the legend border." @@ -1195,10 +1213,18 @@ const EntryGroup = Tuple{Any, Vector{LegendEntry}} linewidth = theme(scene, :linewidth) "The default line color used for LineElements" linecolor = theme(scene, :linecolor) + "The default colormap for LineElements" + linecolormap = theme(scene, :colormap) + "The default colorrange for LineElements" + linecolorrange = automatic "The default line style used for LineElements" linestyle = :solid "The default marker color for MarkerElements" markercolor = theme(scene, :markercolor) + "The default marker colormap for MarkerElements" + markercolormap = theme(scene, :colormap) + "The default marker colorrange for MarkerElements" + markercolorrange = automatic "The default marker for MarkerElements" marker = theme(scene, :marker) "The default marker points used for MarkerElements in normalized coordinates relative to each label patch." @@ -1217,6 +1243,10 @@ const EntryGroup = Tuple{Any, Vector{LegendEntry}} polycolor = theme(scene, :patchcolor) "The default poly stroke color used for PolyElements." polystrokecolor = theme(scene, :patchstrokecolor) + "The default colormap for PolyElements" + polycolormap = theme(scene, :colormap) + "The default colorrange for PolyElements" + polycolorrange = automatic "The orientation of the legend (:horizontal or :vertical)." orientation = :vertical "The gap between each group title and its group." @@ -1232,7 +1262,7 @@ const EntryGroup = Tuple{Any, Vector{LegendEntry}} end end -@Block LScene begin +@Block LScene <: AbstractAxis begin scene::Scene @attributes begin "The height setting of the scene." @@ -1307,13 +1337,13 @@ end "Color of the box border when focused and invalid." bordercolor_focused_invalid = RGBf(1, 0, 0) "Width of the box border." - borderwidth = 2f0 + borderwidth = 1f0 "Padding of the text against the box." - textpadding = (10, 10, 10, 10) + textpadding = (8, 8, 8, 8) "If the textbox is focused and receives text input." focused = false "Corner radius of text box." - cornerradius = 8 + cornerradius = 5 "Corner segments of one rounded corner." cornersegments = 20 "Validator that is called with validate_textbox(string, validator) to determine if the current string is valid. Can by default be a RegEx that needs to match the complete string, or a function taking a string as input and returning a Bool. If the validator is a type T (for example Float64), validation will be `tryparse(string, T)`." @@ -1325,15 +1355,13 @@ end end end -@Block Axis3 begin +@Block Axis3 <: AbstractAxis begin scene::Scene finallimits::Observable{Rect3f} mouseeventhandle::MouseEventHandle scrollevents::Observable{ScrollEvent} keysevents::Observable{KeysEvent} interactions::Dict{Symbol, Tuple{Bool, Any}} - cycler::Cycler - palette::Attributes @attributes begin "The height setting of the scene." height = nothing @@ -1501,6 +1529,12 @@ end ytickwidth = 1 "The z tick width" ztickwidth = 1 + "The size of the xtick marks." + xticksize::Float64 = 6 + "The size of the ytick marks." + yticksize::Float64 = 6 + "The size of the ztick marks." + zticksize::Float64 = 6 "The color of x spine 1 where the ticks are displayed" xspinecolor_1 = :black "The color of y spine 1 where the ticks are displayed" @@ -1537,7 +1571,19 @@ end ygridvisible = true "Controls if the z grid is visible" zgridvisible = true - "The protrusions on the sides of the axis, how much gap space is reserved for labels etc." + """ + The protrusions control how much gap space is reserved for labels etc. on the sides of the `Axis3`. + Unlike `Axis`, `Axis3` currently does not set these values automatically depending on the properties + of ticks and labels. This is because the effective protrusions also depend on the rotation and scaling + of the axis cuboid, which changes whenever the `Axis3` shifts in the layout. Therefore, auto-updating + protrusions could lead to an endless layout update cycle. + + The default value of `30` for all sides is just a heuristic and might lead to collisions of axis + decorations with the `Figure` boundary or other plot elements. If that's the case, you can try increasing + the value(s). + + The `protrusions` attribute accepts a single number for all sides, or a tuple of `(left, right, bottom, top)`. + """ protrusions = 30 "The x ticks" xticks = WilkinsonTicks(5; k_min = 3) @@ -1587,5 +1633,228 @@ end yautolimitmargin = (0.05, 0.05) "The relative margins added to the autolimits in z direction." zautolimitmargin = (0.05, 0.05) + "Controls if the x axis goes rightwards (false) or leftwards (true) in default camera orientation." + xreversed::Bool = false + "Controls if the y axis goes leftwards (false) or rightwards (true) in default camera orientation." + yreversed::Bool = false + "Controls if the z axis goes upwards (false) or downwards (true) in default camera orientation." + zreversed::Bool = false + end +end + +@Block PolarAxis <: AbstractAxis begin + scene::Scene + overlay::Scene + target_rlims::Observable{Tuple{Float64, Float64}} + target_thetalims::Observable{Tuple{Float64, Float64}} + target_theta_0::Observable{Float32} + target_r0::Observable{Float32} + @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::Bool = true + "Controls if the parent layout can adjust to this element's height" + 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 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 + + # Limits & transformation settings + + "The radial limits of the PolarAxis. " + rlimits = (:origin, 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::Int = 1 + "The angular offset for (1, 0) in the PolarAxis. This rotates the axis." + theta_0::Float32 = 0f0 + "Sets the radius at the origin of the PolarAxis such that `r_out = r_in - radius_at_origin`. Can be set to `automatic` to match rmin. Note that this will affect the shape of plotted objects." + radius_at_origin = automatic + "Controls the argument order of the Polar transform. If `theta_as_x = true` it is (θ, r), otherwise (r, θ)." + theta_as_x::Bool = true + "Controls whether `r < 0` (after applying `radius_at_origin`) gets clipped (true) or not (false)." + clip_r::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::Float32 = 2 + "The color of the spine." + spinecolor = :black + "Controls whether the spine is visible." + 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 formatter for the `r` ticks" + rtickformat = Makie.automatic + "The fontsize of the `r` tick labels." + 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::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::Float32 = 4f0 + "Controls if the `r` ticks are visible." + rticklabelsvisible::Bool = inherit(scene, (:Axis, :xticklabelsvisible), true) + "The angle in radians along which the `r` ticks are printed." + 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 = 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 formatter for the `theta` ticks." + thetatickformat = Makie.automatic + "The fontsize of the `theta` tick labels." + 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::Float32 = 4f0 + "The width of the outline of `theta` ticks. Setting this to 0 will remove the outline." + 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::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::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::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::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::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/src/precompiles.jl b/src/precompiles.jl index b6f4f70f46a..1ed6f3e0054 100644 --- a/src/precompiles.jl +++ b/src/precompiles.jl @@ -9,8 +9,25 @@ macro compile(block) end end +precompile(Makie.initialize_block!, (Axis,)) +precompile(_get_glyphcollection_and_linesegments, + (LaTeXStrings.LaTeXString, Int64, Float32, + FreeTypeAbstraction.FTFont, Attributes, + Tuple{Symbol,Symbol}, Quaternion{Float64}, + MakieCore.Automatic, Float64, + ColorTypes.RGBA{Float32}, ColorTypes.RGBA{Float32}, + Int64, Int64, Vec{2,Float32})) + +precompile(Makie.apply_alignment_and_justification!, (Vector{Vector{Makie.GlyphInfo}}, MakieCore.Automatic, + Tuple{Symbol,Symbol})) + +precompile(MakieCore.convert_arguments, (Type{Scatter}, UnitRange{Int64})) +precompile(Makie.assemble_colors, (UnitRange{Int64}, Any, Any)) let @compile_workload begin + f = Figure() + ax = Axis(f[1, 1]) + Makie.initialize_block!(ax) base_path = normpath(joinpath(dirname(pathof(Makie)), "..", "precompile")) shared_precompile = joinpath(base_path, "shared-precompile.jl") include(shared_precompile) @@ -26,3 +43,12 @@ for T in (DragPan, RectangleZoom, LimitReset) precompile(process_interaction, (T, MouseEvent, Axis)) end precompile(process_axis_event, (Axis, MouseEvent)) +precompile(process_interaction, (ScrollZoom, ScrollEvent, Axis)) +precompile(el32convert, (Vector{Int64},)) +precompile(translate, (MoveTo, Vec2{Float64})) +precompile(scale, (MoveTo, Vec{2,Float32})) +precompile(append!, (Vector{FreeType.FT_Vector_}, Vector{FreeType.FT_Vector_})) +precompile(convert_command, (MoveTo,)) +precompile(plot!, (MakieCore.Text{Tuple{Vector{Point{2, Float32}}}},)) +precompile(Vec2{Float64}, (Tuple{Int64,Int64},)) +precompile(MakieCore._create_plot, (typeof(scatter), Dict{Symbol,Any}, UnitRange{Int64})) diff --git a/src/recording.jl b/src/recording.jl index 18cc72b0d6f..a06999a8251 100644 --- a/src/recording.jl +++ b/src/recording.jl @@ -27,13 +27,19 @@ mutable struct RamStepper end function Stepper(figlike::FigureLike; backend=current_backend(), format=:png, visible=false, connect=false, screen_kw...) - screen = getscreen(backend, get_scene(figlike), JuliaNative; visible=visible, start_renderloop=false, screen_kw...) + config = Dict{Symbol,Any}(screen_kw) + get!(config, :visible, visible) + get!(config, :start_renderloop, false) + screen = getscreen(backend, get_scene(figlike), config, JuliaNative) display(screen, figlike; connect=connect) return RamStepper(figlike, screen, Matrix{RGBf}[], format) end function Stepper(figlike::FigureLike, path::String, step::Int; format=:png, backend=current_backend(), visible=false, connect=false, screen_kw...) - screen = getscreen(backend, get_scene(figlike), JuliaNative; visible=visible, start_renderloop=false, screen_kw...) + config = Dict{Symbol,Any}(screen_kw) + get!(config, :visible, visible) + get!(config, :start_renderloop, false) + screen = getscreen(backend, get_scene(figlike), config, JuliaNative) display(screen, figlike; connect=connect) return FolderStepper(figlike, screen, path, format, step) end diff --git a/src/scenes.jl b/src/scenes.jl index 16115de2cc0..fe16c2d868a 100644 --- a/src/scenes.jl +++ b/src/scenes.jl @@ -37,33 +37,6 @@ function SSAO(; radius=nothing, bias=nothing, blur=nothing) return SSAO(_radius, _bias, _blur) end -abstract type AbstractLight end - -""" -A positional point light, shining at a certain color. -Color values can be bigger than 1 for brighter lights. -""" -struct PointLight <: AbstractLight - position::Observable{Vec3f} - radiance::Observable{RGBf} -end - -""" -An environment Light, that uses a spherical environment map to provide lighting. -See: https://en.wikipedia.org/wiki/Reflection_mapping -""" -struct EnvironmentLight <: AbstractLight - intensity::Observable{Float32} - image::Observable{Matrix{RGBf}} -end - -""" -A simple, one color ambient light. -""" -struct AmbientLight <: AbstractLight - color::Observable{RGBf} -end - """ Scene TODO document this @@ -81,7 +54,7 @@ mutable struct Scene <: AbstractScene events::Events "The current pixel area of the Scene." - px_area::Observable{Rect2i} + viewport::Observable{Rect2i} "Whether the scene should be cleared." clear::Observable{Bool} @@ -114,11 +87,12 @@ mutable struct Scene <: AbstractScene ssao::SSAO lights::Vector{AbstractLight} deregister_callbacks::Vector{Observables.ObserverFunction} + cycler::Cycler function Scene( parent::Union{Nothing, Scene}, events::Events, - px_area::Observable{Rect2i}, + viewport::Observable{Rect2i}, clear::Observable{Bool}, camera::Camera, camera_controls::AbstractCamera, @@ -130,12 +104,12 @@ mutable struct Scene <: AbstractScene backgroundcolor::Observable{RGBAf}, visible::Observable{Bool}, ssao::SSAO, - lights::Vector{AbstractLight} + lights::Vector ) scene = new( parent, events, - px_area, + viewport, clear, camera, camera_controls, @@ -147,47 +121,49 @@ mutable struct Scene <: AbstractScene backgroundcolor, visible, ssao, - lights, - Observables.ObserverFunction[] + convert(Vector{AbstractLight}, lights), + Observables.ObserverFunction[], + Cycler() ) - finalizer(empty!, scene) + finalizer(free, scene) return scene end end # on & map versions that deregister when scene closes! -function Observables.on(f, scene::Union{Combined,Scene}, observable::Observable; update=false, priority=0) - to_deregister = on(f, observable; update=update, priority=priority) - push!(scene.deregister_callbacks, to_deregister) +function Observables.on(@nospecialize(f), @nospecialize(scene::Union{Plot,Scene}), @nospecialize(observable::Observable); update=false, priority=0) + to_deregister = on(f, observable; update=update, priority=priority)::Observables.ObserverFunction + push!(scene.deregister_callbacks::Vector{Observables.ObserverFunction}, to_deregister) return to_deregister end -function Observables.onany(f, scene::Union{Combined,Scene}, observables...; priority=0) - to_deregister = onany(f, observables...; priority=priority) - append!(scene.deregister_callbacks, to_deregister) +function Observables.onany(@nospecialize(f), @nospecialize(scene::Union{Plot,Scene}), @nospecialize(observables...); update=false, priority=0) + to_deregister = onany(f, observables...; priority=priority, update=update) + append!(scene.deregister_callbacks::Vector{Observables.ObserverFunction}, to_deregister) return to_deregister end -@inline function Base.map!(@nospecialize(f), scene::Union{Combined,Scene}, result::AbstractObservable, os...; - update::Bool=true) +@inline function Base.map!(f, @nospecialize(scene::Union{Plot,Scene}), result::AbstractObservable, os...; + update::Bool=true, priority = 0) # note: the @inline prevents de-specialization due to the splatting callback = Observables.MapCallback(f, result, os) for o in os - o isa AbstractObservable && on(callback, scene, o) + o isa AbstractObservable && on(callback, scene, o, priority = priority) end update && callback(nothing) return result end -@inline function Base.map(f::F, scene::Union{Combined,Scene}, arg1::AbstractObservable, args...; - ignore_equal_values=false) where {F} +@inline function Base.map(f::F, @nospecialize(scene::Union{Plot,Scene}), arg1::AbstractObservable, args...; + ignore_equal_values=false, priority = 0) where {F} # note: the @inline prevents de-specialization due to the splatting obs = Observable(f(arg1[], map(Observables.to_value, args)...); ignore_equal_values=ignore_equal_values) - map!(f, scene, obs, arg1, args...; update=false) + map!(f, scene, obs, arg1, args...; update=false, priority = priority) return obs end get_scene(scene::Scene) = scene +get_scene(plot::AbstractPlot) = parent_scene(plot) _plural_s(x) = length(x) != 1 ? "s" : "" @@ -215,7 +191,7 @@ function Base.show(io::IO, scene::Scene) end function Scene(; - px_area::Union{Observable{Rect2i}, Nothing} = nothing, + viewport::Union{Observable{Rect2i}, Nothing} = nothing, events::Events = Events(), clear::Union{Automatic, Observable{Bool}, Bool} = automatic, transform_func=identity, @@ -238,12 +214,18 @@ function Scene(; bg = Observable{RGBAf}(to_color(m_theme.backgroundcolor[]); ignore_equal_values=true) - wasnothing = isnothing(px_area) + wasnothing = isnothing(viewport) if wasnothing - px_area = Observable(Recti(0, 0, m_theme.resolution[]); ignore_equal_values=true) + sz = if haskey(m_theme, :resolution) + @warn "Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions." + m_theme.resolution[] + else + m_theme.size[] + end + viewport = Observable(Recti(0, 0, sz); ignore_equal_values=true) end - cam = camera isa Camera ? camera : Camera(px_area) + cam = camera isa Camera ? camera : Camera(viewport) _lights = lights isa Automatic ? AbstractLight[] : lights # if we have an opaque background, automatically set clear to true! @@ -253,59 +235,54 @@ function Scene(; clear = convert(Observable{Bool}, clear) end scene = Scene( - parent, events, px_area, clear, cam, camera_controls, + parent, events, viewport, clear, cam, camera_controls, transformation, plots, m_theme, children, current_screens, bg, visible, ssao, _lights ) camera isa Function && camera(scene) if wasnothing - on(scene, events.window_area, priority = typemax(Int)) do w_area - if !any(x -> x ≈ 0.0, widths(w_area)) && px_area[] != w_area - px_area[] = w_area + on(events.window_area, priority = typemax(Int)) do w_area + if !any(x -> x ≈ 0.0, widths(w_area)) && viewport[] != w_area + viewport[] = w_area end return Consume(false) end end if lights isa Automatic - lightposition = to_value(get(m_theme, :lightposition, nothing)) - if !isnothing(lightposition) - position = if lightposition === :eyeposition - scene.camera.eyeposition - elseif lightposition isa Vec3 - m_theme.lightposition - else - error("Wrong lightposition type, use `:eyeposition` or `Vec3f(...)`") + haskey(m_theme, :lightposition) && @warn("`lightposition` is deprecated. Set `light_direction` instead.") + + if haskey(m_theme, :lights) + copyto!(scene.lights, m_theme.lights[]) + else + haskey(m_theme, :light_direction) || error("Theme must contain `light_direction::Vec3f` or an explicit `lights::Vector`!") + haskey(m_theme, :light_color) || error("Theme must contain `light_color::RGBf` or an explicit `lights::Vector`!") + haskey(m_theme, :camera_relative_light) || @warn("Theme should contain `camera_relative_light::Bool`.") + + if haskey(m_theme, :ambient) + push!(scene.lights, AmbientLight(m_theme[:ambient][])) end - push!(scene.lights, PointLight(position, RGBf(1, 1, 1))) - end - ambient = to_value(get(m_theme, :ambient, nothing)) - if !isnothing(ambient) - push!(scene.lights, AmbientLight(ambient)) + + push!(scene.lights, DirectionalLight( + m_theme[:light_color][], m_theme[:light_direction], + to_value(get(m_theme, :camera_relative_light, false)) + )) end end return scene end -function get_one_light(scene::Scene, Typ) - indices = findall(x-> x isa Typ, scene.lights) - isempty(indices) && return nothing - if length(indices) > 1 - @warn("Only one light supported by backend right now. Using only first light") - end - return scene.lights[indices[1]] -end - -get_point_light(scene::Scene) = get_one_light(scene, PointLight) -get_ambient_light(scene::Scene) = get_one_light(scene, AmbientLight) - +get_directional_light(scene::Scene) = get_one_light(scene.lights, DirectionalLight) +get_point_light(scene::Scene) = get_one_light(scene.lights, PointLight) +get_ambient_light(scene::Scene) = get_one_light(scene.lights, AmbientLight) +default_shading!(plot, scene::Scene) = default_shading!(plot, scene.lights) function Scene( parent::Scene; events=parent.events, - px_area=nothing, + viewport=nothing, clear=false, camera=nothing, camera_controls=parent.camera_controls, @@ -316,10 +293,10 @@ function Scene( if camera !== parent.camera camera_controls = EmptyCamera() end - child_px_area = px_area isa Observable ? px_area : Observable(Rect2i(0, 0, 0, 0); ignore_equal_values=true) + child_px_area = viewport isa Observable ? viewport : Observable(Rect2i(0, 0, 0, 0); ignore_equal_values=true) child = Scene(; events=events, - px_area=child_px_area, + viewport=child_px_area, clear=convert(Observable{Bool}, clear), camera=camera, camera_controls=camera_controls, @@ -329,13 +306,13 @@ function Scene( theme=theme(parent), kw... ) - if isnothing(px_area) - map!(identity, child, child_px_area, parent.px_area) - elseif !(px_area isa Observable) # observables are assumed to be already corrected against the parent to avoid double updates - a = Rect2i(px_area) - on(child, pixelarea(parent)) do p - # make coordinates relative to parent - return Rect2i(minimum(p) .+ minimum(a), widths(a)) + if isnothing(viewport) + map!(identity, child, child_px_area, parent.viewport) + elseif viewport isa Rect2 + child_px_area[] = Rect2i(viewport) + else + if !(viewport isa Observable) + error("viewport must be an Observable{Rect2} or a Rect2") end end push!(parent.children, child) @@ -345,7 +322,7 @@ end # legacy constructor function Scene(parent::Scene, area; kw...) - return Scene(parent; px_area=area, kw...) + return Scene(parent; viewport=area, kw...) end # Base overloads for Scene @@ -362,16 +339,22 @@ function root(scene::Scene) end parent_or_self(scene::Scene) = isroot(scene) ? scene : parent(scene) -GeometryBasics.widths(scene::Scene) = widths(to_value(pixelarea(scene))) +GeometryBasics.widths(scene::Scene) = widths(to_value(viewport(scene))) Base.size(scene::Scene) = Tuple(widths(scene)) Base.size(x::Scene, i) = size(x)[i] + function Base.resize!(scene::Scene, xy::Tuple{Number,Number}) resize!(scene, Recti(0, 0, xy)) end Base.resize!(scene::Scene, x::Number, y::Number) = resize!(scene, (x, y)) function Base.resize!(scene::Scene, rect::Rect2) - pixelarea(scene)[] = rect + viewport(scene)[] = rect + if isroot(scene) + for screen in scene.current_screens + resize!(screen, widths(rect)...) + end + end end # Just indexing into a scene gets you plot 1, plot 2 etc @@ -384,7 +367,7 @@ struct OldAxis end zero_origin(area) = Recti(0, 0, widths(area)) function child(scene::Scene; camera, attributes...) - return Scene(scene, lift(zero_origin, pixelarea(scene)); camera=camera, attributes...) + return Scene(scene; camera=camera, attributes...) end """ @@ -415,31 +398,40 @@ function delete_scene!(scene::Scene) return nothing end -function Base.empty!(scene::Scene) +function free(scene::Scene) + empty!(scene; free=true) + for field in [:backgroundcolor, :viewport, :visible] + Observables.clear(getfield(scene, field)) + end + for screen in copy(scene.current_screens) + delete!(screen, scene) + end + empty!(scene.current_screens) + scene.parent = nothing + return +end + +function Base.empty!(scene::Scene; free=false) foreach(empty!, copy(scene.children)) # clear plots of this scene for plot in copy(scene.plots) delete!(scene, plot) end - for screen in copy(scene.current_screens) - delete!(screen, scene) - end + # clear all child scenes if !isnothing(scene.parent) filter!(x-> x !== scene, scene.parent.children) end - scene.parent = nothing - empty!(scene.current_screens) empty!(scene.children) empty!(scene.plots) empty!(scene.theme) + # conditional, since in free we dont want this! + free || merge_without_obs!(scene.theme, CURRENT_DEFAULT_THEME) + disconnect!(scene.camera) scene.camera_controls = EmptyCamera() - for field in [:backgroundcolor, :px_area, :visible] - Observables.clear(getfield(scene, field)) - end for fieldname in (:rotation, :translation, :scale, :transform_func, :model) Observables.clear(getfield(scene.transformation, fieldname)) end @@ -450,20 +442,22 @@ function Base.empty!(scene::Scene) return nothing end +function Base.push!(plot::Plot, subplot) + subplot.parent = plot + push!(plot.plots, subplot) +end -Base.push!(scene::Combined, subscene) = nothing # Combined plots add themselves uppon creation - -function Base.push!(scene::Scene, plot::AbstractPlot) +function Base.push!(scene::Scene, @nospecialize(plot::AbstractPlot)) push!(scene.plots, plot) - plot isa Combined || (plot.parent[] = scene) for screen in scene.current_screens - insert!(screen, scene, plot) + Base.invokelatest(insert!, screen, scene, plot) end end function Base.delete!(screen::MakieScreen, ::Scene, ::AbstractPlot) - @warn "Deleting plots not implemented for backend: $(typeof(screen))" + @debug "Deleting plots not implemented for backend: $(typeof(screen))" end + function Base.delete!(screen::MakieScreen, ::Scene) # This may not be necessary for every backed @debug "Deleting scenes not implemented for backend: $(typeof(screen))" @@ -493,21 +487,6 @@ function Base.delete!(scene::Scene, plot::AbstractPlot) free(plot) end -function Base.push!(scene::Scene, child::Scene) - push!(scene.children, child) - disconnect!(child.camera) - observables = map([:view, :projection, :projectionview, :resolution, :eyeposition]) do field - return lift(getfield(scene.camera, field)) do val - getfield(child.camera, field)[] = val - getfield(child.camera, field)[] = val - return - end - end - cameracontrols!(child, observables) - child.parent = scene - return scene -end - events(x) = events(get_scene(x)) events(scene::Scene) = scene.events events(scene::SceneLike) = events(scene.parent) @@ -527,9 +506,14 @@ end cameracontrols!(scene::SceneLike, cam) = cameracontrols!(parent(scene), cam) cameracontrols!(x, cam) = cameracontrols!(get_scene(x), cam) -pixelarea(x) = pixelarea(get_scene(x)) -pixelarea(scene::Scene) = scene.px_area -pixelarea(scene::SceneLike) = pixelarea(scene.parent) +viewport(x) = viewport(get_scene(x)) +""" + viewport(scene::Scene) + +Gets the viewport of the scene in device independent units as an `Observable{Rect2{Int}}`. +""" +viewport(scene::Scene) = scene.viewport +viewport(scene::SceneLike) = viewport(scene.parent) plots(x) = plots(get_scene(x)) plots(scene::SceneLike) = scene.plots @@ -546,21 +530,8 @@ function plots_from_camera(scene::Scene, camera::Camera, list=AbstractPlot[]) list end -""" -Flattens all the combined plots and returns a Vector of Atomic plots -""" -function flatten_combined(plots::Vector, flat=AbstractPlot[]) - for elem in plots - if (elem isa Combined) - flatten_combined(elem.plots, flat) - else - push!(flat, elem) - end - end - flat -end -function insertplots!(screen::AbstractDisplay, scene::Scene) +function insertplots!(@nospecialize(screen::AbstractDisplay), scene::Scene) for elem in scene.plots insert!(screen, scene, elem) end @@ -585,7 +556,7 @@ function center!(scene::Scene, padding=0.01, exclude = not_in_data_space) end parent_scene(x) = parent_scene(get_scene(x)) -parent_scene(x::Combined) = parent_scene(parent(x)) +parent_scene(x::Plot) = parent_scene(parent(x)) parent_scene(x::Scene) = x Base.isopen(x::SceneLike) = events(x).window_open[] @@ -625,22 +596,22 @@ end const FigureLike = Union{Scene, Figure, FigureAxisPlot} """ - is_atomic_plot(plot::Combined) + is_atomic_plot(plot::Plot) Defines what Makie considers an atomic plot, used in `collect_atomic_plots`. Backends may have a different definition of what is considered an atomic plot, but instead of overloading this function, they should create their own definition and pass it to `collect_atomic_plots` """ -is_atomic_plot(plot::Combined) = isempty(plot.plots) +is_atomic_plot(plot::Plot) = isempty(plot.plots) """ collect_atomic_plots(scene::Scene, plots = AbstractPlot[]; is_atomic_plot = is_atomic_plot) - collect_atomic_plots(x::Combined, plots = AbstractPlot[]; is_atomic_plot = is_atomic_plot) + collect_atomic_plots(x::Plot, plots = AbstractPlot[]; is_atomic_plot = is_atomic_plot) Collects all plots in the provided `<: ScenePlot` and returns a vector of all plots which satisfy `is_atomic_plot`, which defaults to Makie's definition of `Makie.is_atomic_plot`. """ -function collect_atomic_plots(xplot::Combined, plots=AbstractPlot[]; is_atomic_plot=is_atomic_plot) +function collect_atomic_plots(xplot::Plot, plots=AbstractPlot[]; is_atomic_plot=is_atomic_plot) if is_atomic_plot(xplot) # Atomic plot! push!(plots, xplot) @@ -664,5 +635,3 @@ function collect_atomic_plots(scene::Scene, plots=AbstractPlot[]; is_atomic_plot collect_atomic_plots(scene.children, plots; is_atomic_plot=is_atomic_plot) plots end - -Base.@deprecate flatten_plots(scenelike) collect_atomic_plots(scenelike) diff --git a/src/specapi.jl b/src/specapi.jl new file mode 100644 index 00000000000..fe6eec19b61 --- /dev/null +++ b/src/specapi.jl @@ -0,0 +1,742 @@ + +using GridLayoutBase: GridLayoutBase + +import GridLayoutBase: GridPosition, Side, ContentSize, GapSize, AlignMode, Inner, GridLayout, GridSubposition + + +function get_recipe_function(name::Symbol) + if hasproperty(Makie, name) + return getfield(Makie, name) + else + return nothing + end +end + +@nospecialize +""" + PlotSpec(plottype, args...; kwargs...) + +Object encoding positional arguments (`args`), a `NamedTuple` of attributes (`kwargs`) +as well as plot type `P` of a basic plot. +""" +struct PlotSpec + type::Symbol + args::Vector{Any} + kwargs::Dict{Symbol, Any} + function PlotSpec(type::Symbol, args...; kwargs...) + type_str = string(type) + if type_str[end] == '!' + error("PlotSpec objects are supposed to be used without !, unless when using `S.$(type)(axis::P.Axis, args...; kwargs...)`") + end + if !isuppercase(type_str[1]) + func = get_recipe_function(type) + func === nothing && error("PlotSpec need to be existing recipes or Makie plot objects. Found: $(type_str)") + plot_type = Plot{func} + type = plotsym(plot_type) + @warn("PlotSpec objects are supposed to be title case. Found: $(type_str). Please use $(type) instead.") + end + kw = Dict{Symbol,Any}() + for (k, v) in kwargs + # convert eagerly, so that we have stable types for matching later + # E.g. so that PlotSpec(; color = :red) has the same type as PlotSpec(; color = RGBA(1, 0, 0, 1)) + if v isa Cycled # special case for conversions needing a scene + kw[k] = v + elseif v isa Observable + error("PlotSpec are supposed to be used without Observables") + else + try + # Really unfortunate! + # Recipes don't have convert_attribute + # (e.g. band(...; color=:y)) + # So on error we don't convert for now via try catch + # Since we also dont have an API to figure out if a convert is defined correctly + # TODO, I think we can do this more elegantly but will need a bit of a convert_attribute refactor + kw[k] = convert_attribute(v, Key{k}(), Key{type}()) + catch e + kw[k] = v + end + end + end + return new(type, Any[args...], kw) + end + PlotSpec(args...; kwargs...) = new(:plot, args...; kwargs...) +end +@specialize + +Base.getindex(p::PlotSpec, i::Int) = getindex(p.args, i) +Base.getindex(p::PlotSpec, i::Symbol) = getproperty(p.kwargs, i) + +to_plotspec(::Type{P}, args; kwargs...) where {P} = PlotSpec(plotsym(P), args...; kwargs...) + +function to_plotspec(::Type{P}, p::PlotSpec; kwargs...) where {P} + S = plottype(p) + return PlotSpec(plotsym(plottype(P, S)), p.args...; p.kwargs..., kwargs...) +end + +plottype(p::PlotSpec) = getfield(Makie, p.type) + +struct BlockSpec + type::Symbol # Type as :Scatter, :BarPlot + kwargs::Dict{Symbol,Any} + plots::Vector{PlotSpec} +end + +function BlockSpec(typ::Symbol, args...; plots::Vector{PlotSpec}=PlotSpec[], kw...) + attr = Dict{Symbol,Any}(kw) + if typ == :Legend + # TODO, this is hacky and works around the fact, + # that legend gets its legend elements from the positional arguments + # But we can only update them via legend.entrygroups + defaults = block_defaults(:Legend, attr, nothing) + entrygroups = to_entry_group(Attributes(defaults), args...) + attr[:entrygroups] = entrygroups + return BlockSpec(typ, attr, plots) + else + if !isempty(args) + error("BlockSpecs, with an exception for Legend, don't support positional arguments yet.") + end + return BlockSpec(typ, attr, plots) + end +end + +const GridLayoutPosition = Tuple{UnitRange{Int},UnitRange{Int},Side} + +to_span(range::UnitRange{Int}, span::UnitRange{Int}) = (range.start < span.start || range.stop > span.stop) ? error("Range $range not completely covered by spanning range $span.") : range +to_span(range::Int, span::UnitRange{Int}) = (range < span.start || range > span.stop) ? error("Range $range not completely covered by spanning range $span.") : range:range +to_span(::Colon, span::UnitRange{Int}) = span +to_gridposition(rows_cols::Tuple{Any,Any}, rowspan, colspan) = to_gridposition((rows_cols..., Inner()), rowspan, colspan) +to_gridposition(rows_cols_side::Tuple{Any,Any,Any}, rowspan, colspan) = (to_span(rows_cols_side[1], rowspan), to_span(rows_cols_side[2], colspan), rows_cols_side[3]) + +rangeunion(r1, r2::UnitRange) = min(r1.start, r2.start):max(r1.stop, r2.stop) +rangeunion(r1, r2::Int) = min(r1.start, r2):max(r1.stop, r2) +rangeunion(r1, r2::Colon) = r1 + +struct GridLayoutSpec + content::Vector{Pair{GridLayoutPosition,Union{GridLayoutSpec,BlockSpec}}} + + size::Tuple{Int, Int} + offsets::Tuple{Int, Int} + + colsizes::Vector{ContentSize} + rowsizes::Vector{ContentSize} + colgaps::Vector{GapSize} + rowgaps::Vector{GapSize} + alignmode::AlignMode + tellheight::Bool + tellwidth::Bool + halign::Float64 + valign::Float64 + + function GridLayoutSpec( + content::AbstractVector{<:Pair}; + colsizes = nothing, + rowsizes = nothing, + colgaps = nothing, + rowgaps = nothing, + alignmode::AlignMode = GridLayoutBase.Inside(), + tellheight::Bool = true, + tellwidth::Bool = true, + halign::Union{Symbol,Real} = :center, + valign::Union{Symbol,Real} = :center, + ) + + rowspan, colspan = foldl(content; init = (1:1, 1:1)) do (rows, cols), ((_rows, _cols, _...), _) + rangeunion(rows, _rows), rangeunion(cols, _cols) + end + + content = map(content) do (position, x) + p = Pair{GridLayoutPosition,Union{GridLayoutSpec,BlockSpec}}(to_gridposition(position, rowspan, colspan), x) + return p + end + + nrows = length(rowspan) + ncols = length(colspan) + colsizes = GridLayoutBase.convert_contentsizes(ncols, colsizes) + rowsizes = GridLayoutBase.convert_contentsizes(nrows, rowsizes) + default_rowgap = Fixed(16) # TODO: where does this come from? + default_colgap = Fixed(16) # TODO: where does this come from? + colgaps = GridLayoutBase.convert_gapsizes(ncols - 1, colgaps, default_colgap) + rowgaps = GridLayoutBase.convert_gapsizes(nrows - 1, rowgaps, default_rowgap) + + halign = GridLayoutBase.halign2shift(halign) + valign = GridLayoutBase.valign2shift(valign) + + return new( + content, + (nrows, ncols), + (rowspan[1] - 1, colspan[1] - 1), + colsizes, + rowsizes, + colgaps, + rowgaps, + alignmode, + tellheight, + tellwidth, + halign, + valign, + ) + end +end + +const Layoutable = Union{GridLayout,Block} +const LayoutableSpec = Union{GridLayoutSpec,BlockSpec} +const LayoutEntry = Pair{GridLayoutPosition,LayoutableSpec} + +GridLayoutSpec(v::AbstractVector; kwargs...) = GridLayoutSpec(reshape(v, :, 1); kwargs...) +function GridLayoutSpec(v::AbstractMatrix; kwargs...) + indices = vec([Tuple(c) for c in CartesianIndices(v)]) + pairs = [ + LayoutEntry((i:i, j:j, GridLayoutBase.Inner()), v[i, j]) for (i, j) in indices + ] + return GridLayoutSpec(pairs; kwargs...) +end + +GridLayoutSpec(contents...; kwargs...) = GridLayoutSpec([contents...]; kwargs...) + +""" +apply for return type PlotSpec +""" +function apply_convert!(P, attributes::Attributes, x::PlotSpec) + args, kwargs = x.args, x.kwargs + # Note that kw_args in the plot spec that are not part of the target plot type + # will end in the "global plot" kw_args (rest) + for (k, v) in pairs(kwargs) + attributes[k] = v + end + return (plottype(plottype(x), P), (args...,)) +end + +function apply_convert!(P, ::Attributes, x::AbstractVector{PlotSpec}) + return (PlotList, (x,)) +end + +""" +apply for return type + (args...,) +""" +apply_convert!(P, ::Attributes, x::Tuple) = (P, x) + +function MakieCore.argtypes(plot::PlotSpec) + args_converted = convert_arguments(plottype(plot), plot.args...) + return MakieCore.argtypes(args_converted) +end + +""" +See documentation for specapi. +""" +struct _SpecApi end +const SpecApi = _SpecApi() + +function Base.getproperty(::_SpecApi, field::Symbol) + field === :GridLayout && return GridLayoutSpec + # TODO, we wanted to track all recipe names in a set + # in MakieCore via the recipe macro, but due to precompilation & caching + # It seems impossible to merge the recipes from all modules + # Since precompilation will cache only MakieCore's state + # And once everything is compiled, and MakieCore is loaded into a package + # The names are loaded from cache and dont contain anything after MakieCore. + func = get_recipe_function(field) + if isnothing(func) + error("$(field) neither a recipe, Makie plotting object or a Block (like Axis, Legend, etc).") + elseif func isa Function + sym = plotsym(Plot{func}) + if (sym === :plot) # fallback for plotsym, so not found! + error("$(field) neither a recipe, Makie plotting object or a Block (like Axis, Legend, etc).") + end + @warn("PlotSpec objects are supposed to be title case. Found: $(field). Please use $(sym) instead.") + return (args...; kw...) -> PlotSpec(sym, args...; kw...) + elseif func <: Plot + return (args...; kw...) -> PlotSpec(field, args...; kw...) + elseif func <: Block + return (args...; kw...) -> BlockSpec(field, args...; kw...) + else + error("$(field) not a valid Block or Plot function") + end +end + +# We use this function to decide which plots to reuse + update instead of re-creating. +# Comparison based entirely of types inside args + kwargs. +# This will return false for the same plotspec with a new attribute +# E.g. `compare_spec(S.Scatter(1:4; color=:red), S.Scatter(1:4; marker=:circle))` +# While we could easily update this, we don't want to, since we're +# pessimistic about what's updatable and to avoid issues with +# Needing to reset attributes to their defaults, at the cost of re-creating more plots than necessary. +# TODO when focussing better performance, this is one of the first things we want to try +function compare_specs(a::PlotSpec, b::PlotSpec) + a.type === b.type || return false + length(a.args) == length(b.args) || return false + all(i-> typeof(a.args[i]) == typeof(b.args[i]), 1:length(a.args)) || return false + + length(a.kwargs) == length(b.kwargs) || return false + ka = keys(a.kwargs) + kb = keys(b.kwargs) + ka == kb || return false + all(k -> typeof(a.kwargs[k]) == typeof(b.kwargs[k]), ka) || return false + return true +end + +@inline function is_different(a, b) + # First check if they are the same object + # This disallows mutating PlotSpec arguments in place + a === b && return false + # If they're not the same objcets, we see if they contain the same values + a == b && return false + return true +end + +function update_plot!(obs_to_notify, plot::AbstractPlot, oldspec::PlotSpec, spec::PlotSpec) + # Update args in plot `input_args` list + for i in eachindex(spec.args) + # we should only call update_plot!, if compare_spec(spec_plot_got_created_from, spec) == true, + # Which should guarantee, that args + kwargs have the same length and types! + arg_obs = plot.args[i] + prev_val = oldspec.args[i] + if is_different(prev_val, spec.args[i]) # only update if different + arg_obs.val = spec.args[i] + push!(obs_to_notify, arg_obs) + end + end + scene = parent_scene(plot) + # Update attributes + for (attribute, new_value) in spec.kwargs + old_attr = plot[attribute] + # only update if different + if is_different(old_attr[], new_value) + if new_value isa Cycled + old_attr.val = to_color(scene, attribute, new_value) + else + @debug("updating kw $attribute") + old_attr.val = new_value + end + push!(obs_to_notify, old_attr) + end + end + # Cycling needs to be handled separately sadly, + # since they're implicitely mutating attributes, e.g. if I re-use a plot + # that has been on cycling position 2, and now I re-use it for the first plot in the list + # it will need to change to the color of cycling position 1 + if haskey(plot, :cycle) + cycle = get_cycle_for_plottype(plot.cycle[]) + uncycled = Set{Symbol}() + for (attr_vec, _) in cycle.cycle + for attr in attr_vec + if !haskey(spec.kwargs, attr) + push!(uncycled, attr) + end + end + end + + if !isempty(uncycled) + # remove all attributes that don't need cycling + for (attr_vec, _) in cycle.cycle + filter!(x -> x in uncycled, attr_vec) + end + add_cycle_attribute!(plot, scene, cycle) + append!(obs_to_notify, (plot[k] for k in uncycled)) + end + end + return +end + +""" + plotlist!( + [ + PlotSpec(:scatter, args...; kwargs...), + PlotSpec(:lines, args...; kwargs...), + ] + ) + +Plots a list of PlotSpec's, which can be an observable, making it possible to create efficiently animated plots with the following API: + +## Example +```julia +using GLMakie +import Makie.SpecApi as S + +fig = Figure() +ax = Axis(fig[1, 1]) +plots = Observable([S.heatmap(0 .. 1, 0 .. 1, Makie.peaks()), S.lines(0 .. 1, sin.(0:0.01:1); color=:blue)]) +pl = plot!(ax, plots) +display(fig) + +# Updating the plot dynamically +plots[] = [S.heatmap(0 .. 1, 0 .. 1, Makie.peaks()), S.lines(0 .. 1, sin.(0:0.01:1); color=:red)] +plots[] = [ + S.image(0 .. 1, 0 .. 1, Makie.peaks()), + S.poly(Rect2f(0.45, 0.45, 0.1, 0.1)), + S.lines(0 .. 1, sin.(0:0.01:1); linewidth=10, color=Makie.resample_cmap(:viridis, 101)), +] + +plots[] = [ + S.surface(0..1, 0..1, Makie.peaks(); colormap = :viridis, translation = Vec3f(0, 0, -1)), +] +``` +""" +@recipe(PlotList, plotspecs) do scene + Attributes() +end + +convert_arguments(::Type{<:AbstractPlot}, args::AbstractArray{<:PlotSpec}) = (args,) +plottype(::AbstractVector{PlotSpec}) = PlotList + +# Since we directly plot into the parent scene (hacky), we need to overload these +Base.insert!(::MakieScreen, ::Scene, ::PlotList) = nothing + +function Base.show(io::IO, ::MIME"text/plain", spec::PlotSpec) + args = join(map(x -> string("::", typeof(x)), spec.args), ", ") + kws = join([string(k, " = ", typeof(v)) for (k, v) in spec.kwargs], ", ") + println(io, "S.", spec.type, "($args; $kws)") + return +end + +function Base.show(io::IO, spec::PlotSpec) + args = join(map(x -> string("::", typeof(x)), spec.args), ", ") + kws = join([string(k, " = ", typeof(v)) for (k, v) in spec.kwargs], ", ") + println(io, "S.", spec.type, "($args; $kws)") + return +end + +function to_plot_object(ps::PlotSpec) + P = plottype(ps) + return P((ps.args...,), copy(ps.kwargs)) +end + +function find_reusable_plot(plotspec::PlotSpec, reusable_plots::IdDict{PlotSpec,Plot}) + for (spec, plot) in reusable_plots + if compare_specs(spec, plotspec) + return plot, spec + end + end + return nothing, nothing +end + +function diff_plotlist!(scene::Scene, plotspecs::Vector{PlotSpec}, obs_to_notify, reusable_plots, + plotlist::Union{Nothing,PlotList}=nothing) + new_plots = IdDict{PlotSpec,Plot}() # needed to be mutated + empty!(scene.cycler.counters) + # Global list of observables that need updating + # Updating them all at once in the end avoids problems with triggering updates while updating + # And at some point we may be able to optimize notify(list_of_observables) + empty!(obs_to_notify) + for plotspec in plotspecs + # we need to compare by types with compare_specs, since we can only update plots if the types of all attributes match + reused_plot, old_spec = find_reusable_plot(plotspec, reusable_plots) + if isnothing(reused_plot) + @debug("Creating new plot for spec") + # Create new plot, store it into our `cached_plots` dictionary + plot = plot!(scene, to_plot_object(plotspec)) + if !isnothing(plotlist) + push!(plotlist.plots, plot) + end + new_plots[plotspec] = plot + else + @debug("updating old plot with spec") + # Delete the plots from reusable_plots, so that we don't re-use it multiple times! + delete!(reusable_plots, old_spec) + update_plot!(obs_to_notify, reused_plot, old_spec, plotspec) + new_plots[plotspec] = reused_plot + end + end + return new_plots +end + +function update_plotspecs!(scene::Scene, list_of_plotspecs::Observable, plotlist::Union{Nothing, PlotList}=nothing) + # Cache plots here so that we aren't re-creating plots every time; + # if a plot still exists from last time, update it accordingly. + # If the plot is removed from `plotspecs`, we'll delete it from here + # and re-create it if it ever returns. + unused_plots = IdDict{PlotSpec,Plot}() + obs_to_notify = Observable[] + function update_plotlist(plotspecs) + # Global list of observables that need updating + # Updating them all at once in the end avoids problems with triggering updates while updating + # And at some point we may be able to optimize notify(list_of_observables) + empty!(obs_to_notify) + empty!(scene.cycler.counters) # Reset Cycler + # diff_plotlist! deletes all plots that get re-used from unused_plots + # so, this will become our list of unused plots! + new_plots = diff_plotlist!(scene, plotspecs, obs_to_notify, unused_plots, plotlist) + # Next, delete all plots that we haven't used + # TODO, we could just hide them, until we reach some max_plots_to_be_cached, so that we re-create less plots. + for (_, plot) in unused_plots + if !isnothing(plotlist) + filter!(x -> x !== plot, plotlist.plots) + end + delete!(scene, plot) + end + # Transfer all new plots into unused_plots for the next update! + @assert !any(x-> x in unused_plots, new_plots) + empty!(unused_plots) + merge!(unused_plots, new_plots) + # finally, notify all changes at once + foreach(notify, obs_to_notify) + return + end + l = Base.ReentrantLock() + on(scene, list_of_plotspecs; update=true) do plotspecs + lock(l) do + update_plotlist(plotspecs) + end + return + end + return +end + +function Makie.plot!(p::PlotList{<: Tuple{<: AbstractArray{PlotSpec}}}) + scene = Makie.parent_scene(p) + update_plotspecs!(scene, p[1], p) + return +end + +## BlockSpec + +function compare_layout_slot((anesting, ap, a)::Tuple{Int,GP,BlockSpec}, (bnesting, bp, b)::Tuple{Int,GP,BlockSpec}) where {GP<:GridLayoutPosition} + anesting !== bnesting && return false + a.type !== b.type && return false + ap !== bp && return false + return true +end + +function compare_layout_slot((anesting, ap, a)::Tuple{Int,GP, GridLayoutSpec}, (bnesting, bp, b)::Tuple{Int,GP, GridLayoutSpec}) where {GP <: GridLayoutPosition} + anesting !== bnesting && return false + ap !== bp && return false + for (ac, bc) in zip(a.content, b.content) + compare_layout_slot((anesting + 1, ac[1], ac[2]), (bnesting + 1, bc[1], bc[2])) || return false + end + return true +end + +compare_layout_slot(a, b) = false # types dont match + +function to_layoutable(parent, position::GridLayoutPosition, spec::BlockSpec) + BType = getfield(Makie, spec.type) + # TODO forward kw + block = BType(get_top_parent(parent); spec.kwargs...) + parent[position...] = block + return block +end + +function to_layoutable(parent, position::GridLayoutPosition, spec::GridLayoutSpec) + # TODO pass colsizes etc + gl = GridLayout(length(spec.rowsizes), length(spec.colsizes); + colsizes=spec.colsizes, + rowsizes=spec.rowsizes, + colgaps=spec.colgaps, + rowgaps=spec.rowgaps, + alignmode=spec.alignmode, + tellwidth=spec.tellwidth, + tellheight=spec.tellheight, + halign=spec.halign, + valign=spec.valign) + parent[position...] = gl + return gl +end + +function update_layoutable!(block::T, plot_obs, old_spec::BlockSpec, spec::BlockSpec) where T <: Block + old_attr = keys(old_spec.kwargs) + new_attr = keys(spec.kwargs) + # attributes that have been set previously and need to get unset now + reset_to_defaults = setdiff(old_attr, new_attr) + if !isempty(reset_to_defaults) + default_attrs = default_attribute_values(T, block.blockscene) + for attr in reset_to_defaults + setproperty!(block, attr, default_attrs[attr]) + end + end + # Attributes needing an update + to_update = setdiff(new_attr, reset_to_defaults) + for key in to_update + val = spec.kwargs[key] + prev_val = to_value(getproperty(block, key)) + if is_different(val, prev_val) + setproperty!(block, key, val) + end + end + # Reset the cycler + if hasproperty(block, :scene) + empty!(block.scene.cycler.counters) + end + if T <: AbstractAxis + plot_obs[] = spec.plots + scene = get_scene(block) + if any(needs_tight_limits, scene.plots) + tightlimits!(block) + end + end + return +end + +function to_gl_key(key::Symbol) + key === :colgaps && return :addedcolgaps + key === :rowgaps && return :addedrowgaps + return key +end + +function update_layoutable!(layout::GridLayout, obs, old_spec::Union{GridLayoutSpec, Nothing}, spec::GridLayoutSpec) + # Block updates until very end where all children etc got deleted! + layout.block_updates = true + keys = (:alignmode, :tellwidth, :tellheight, :halign, :valign) + layout.size = spec.size + layout.offsets = spec.offsets + for k in keys + # TODO! The gridlayout in the top parent figure has a padding from the Figure + # Since in the SpecApi we can do nested specs with whole figure, we can't create the default there since + # We don't know which GridLayout will be the main parent. + # So for now, we just ignore the padding for the top level gridlayout, since we assume the padding in the figurespec is wrong! + if layout.parent isa Figure && k == :alignmode + continue + end + old_val = isnothing(old_spec) ? nothing : getproperty(old_spec, k) + new_val = getproperty(spec, k) + if is_different(old_val, new_val) + value_obs = getfield(layout, k) + if value_obs isa Observable + value_obs[] = new_val + end + end + end + # TODO update colsizes etc + for field in [:size, :offsets, :colsizes, :rowsizes, :colgaps, :rowgaps] + old_val = isnothing(old_spec) ? nothing : getfield(old_spec, field) + new_val = getfield(spec, field) + if is_different(old_val, new_val) + setfield!(layout, to_gl_key(field), new_val) + end + end + return +end + +function find_layoutable(spec, layoutables) + for (i, (key, value)) in enumerate(layoutables) + if compare_layout_slot(key, spec) + return i, key, value + end + end + return 0, nothing, nothing +end + + +function update_gridlayout!(gridlayout::GridLayout, nesting::Int, oldgridspec::Union{Nothing, GridLayoutSpec}, + gridspec::GridLayoutSpec, previous_contents, new_layoutables) + + update_layoutable!(gridlayout, nothing, oldgridspec, gridspec) + + for (position, spec) in gridspec.content + # we need to compare by types with compare_specs, since we can only update plots if the types of all attributes match + idx, old_key, layoutable_obs = find_layoutable((nesting, position, spec), previous_contents) + if isnothing(layoutable_obs) + @debug("Creating new content for spec") + # Create new plot, store it into `new_layoutables` + new_layoutable = to_layoutable(gridlayout, position, spec) + obs = Observable(PlotSpec[]) + if new_layoutable isa AbstractAxis + obs = Observable(spec.plots) + scene = get_scene(new_layoutable) + update_plotspecs!(scene, obs) + if any(needs_tight_limits, scene.plots) + tightlimits!(new_layoutable) + end + update_state_before_display!(new_layoutable) + elseif new_layoutable isa GridLayout + # Make sure all plots & blocks are inserted + update_gridlayout!(new_layoutable, nesting + 1, spec, spec, previous_contents, + new_layoutables) + end + push!(new_layoutables, (nesting, position, spec) => (new_layoutable, obs)) + else + @debug("updating old block with spec") + # Make sure we don't double re-use a layoutable + splice!(previous_contents, idx) + (_, _, old_spec) = old_key + (layoutable, plot_obs) = layoutable_obs + gridlayout[position...] = layoutable + if layoutable isa GridLayout + update_gridlayout!(layoutable, nesting + 1, old_spec, spec, previous_contents, new_layoutables) + else + update_layoutable!(layoutable, plot_obs, old_spec, spec) + update_state_before_display!(layoutable) + end + # Carry over to cache it in new_layoutables + push!(new_layoutables, (nesting, position, spec) => (layoutable, plot_obs)) + end + end +end + +get_layout!(fig::Figure) = fig.layout +get_layout!(gp::Union{GridSubposition,GridPosition}) = GridLayoutBase.get_layout_at!(gp; createmissing=true) + +# We use this to decide if we can re-use a plot. +# (nesting_level_in_layout, position_in_layout, spec) +const LayoutableKey = Tuple{Int,GridLayoutPosition,LayoutableSpec} + +delete_layoutable!(block::Block) = delete!(block) +function delete_layoutable!(grid::GridLayout) + gc = grid.layoutobservables.gridcontent[] + if !isnothing(gc) + GridLayoutBase.remove_from_gridlayout!(gc) + end + return +end + +function update_fig!(fig::Union{Figure,GridPosition,GridSubposition}, layout_obs::Observable{GridLayoutSpec}) + # Global list of all layoutables. The LayoutableKey includes a nesting, so that we can keep even nested layouts in one global list. + # Vector of Pairs should allow to have an identical key without overwriting the previous value + unused_layoutables = Pair{LayoutableKey, Tuple{Layoutable,Observable{Vector{PlotSpec}}}}[] + new_layoutables = Pair{LayoutableKey,Tuple{Layoutable,Observable{Vector{PlotSpec}}}}[] + sizehint!(unused_layoutables, 50) + sizehint!(new_layoutables, 50) + l = Base.ReentrantLock() + layout = get_layout!(fig) + + on(get_topscene(fig), layout_obs; update=true) do layout_spec + lock(l) do + # For each update we look into `unused_layoutables` to see if we can re-use a layoutable (GridLayout/Block). + # Every re-used layoutable and every newly created gets pushed into `new_layoutables`, + # while it gets removed from `unused_layoutables`. + empty!(new_layoutables) + update_gridlayout!(layout, 1, nothing, layout_spec, unused_layoutables, new_layoutables) + # Everything that still is in unused_layoutables is not used anymore and can be deleted + for (key, (layoutable, obs)) in unused_layoutables + delete_layoutable!(layoutable) + Observables.clear(obs) + end + layouts_to_update = Set{GridLayout}([layout]) + for (_, (content, _)) in new_layoutables + if content isa GridLayout + push!(layouts_to_update, content) + else + gc = GridLayoutBase.gridcontent(content) + push!(layouts_to_update, gc.parent) + end + end + for l in layouts_to_update + l.block_updates = false + GridLayoutBase.update!(l) + end + # Finally transfer all new_layoutables into reusable_layoutables, + # since in the next update they will be the once we re-use + # TODO: Is this actually more efficent for GC then `reusable_layoutables=new_layoutables` ? + empty!(unused_layoutables) + append!(unused_layoutables, new_layoutables) + return + end + end + return fig +end + +args_preferred_axis(::GridLayoutSpec) = FigureOnly + +plot!(plot::Plot{MakieCore.plot,Tuple{GridLayoutSpec}}) = plot + +function plot!(fig::Union{Figure, GridLayoutBase.GridPosition}, plot::Plot{MakieCore.plot,Tuple{GridLayoutSpec}}) + figure = fig isa Figure ? fig : get_top_parent(fig) + connect_plot!(figure.scene, plot) + update_fig!(fig, plot[1]) + return fig +end + +function apply_convert!(P, ::Attributes, x::GridLayoutSpec) + return (Plot{plot}, (x,)) +end + +MakieCore.argtypes(::GridLayoutSpec) = Tuple{Nothing} diff --git a/src/stats/boxplot.jl b/src/stats/boxplot.jl index 33d11eeeefc..e6cbe36c2a0 100644 --- a/src/stats/boxplot.jl +++ b/src/stats/boxplot.jl @@ -36,6 +36,7 @@ The boxplot has 3 components: weights = automatic, color = theme(scene, :patchcolor), colormap = theme(scene, :colormap), + colorscale=identity, colorrange = automatic, orientation = :vertical, # box and dodging @@ -220,6 +221,7 @@ function Makie.plot!(plot::BoxPlot) color = boxcolor, colorrange = plot[:colorrange], colormap = plot[:colormap], + colorscale = plot[:colorscale], strokecolor = plot[:strokecolor], strokewidth = plot[:strokewidth], midlinecolor = plot[:mediancolor], diff --git a/src/stats/crossbar.jl b/src/stats/crossbar.jl index 6bcd61abb03..5fb77c29f0f 100644 --- a/src/stats/crossbar.jl +++ b/src/stats/crossbar.jl @@ -25,6 +25,7 @@ It is most commonly used as part of the `boxplot`. t = Theme( color=theme(scene, :patchcolor), colormap=theme(scene, :colormap), + colorscale=identity, colorrange=automatic, orientation=:vertical, # box and dodging @@ -112,6 +113,7 @@ function Makie.plot!(plot::CrossBar) color=plot.color, colorrange=plot.colorrange, colormap=plot.colormap, + colorscale=plot.colorscale, strokecolor=plot.strokecolor, strokewidth=plot.strokewidth, inspectable = plot[:inspectable] diff --git a/src/stats/density.jl b/src/stats/density.jl index 2d667196268..82034cce2e3 100644 --- a/src/stats/density.jl +++ b/src/stats/density.jl @@ -37,6 +37,7 @@ $(ATTRIBUTES) Theme( color = theme(scene, :patchcolor), colormap = theme(scene, :colormap), + colorscale = identity, colorrange = Makie.automatic, strokecolor = theme(scene, :patchstrokecolor), strokewidth = theme(scene, :patchstrokewidth), @@ -113,7 +114,7 @@ function plot!(plot::Density{<:Tuple{<:AbstractVector}}) end end - band!(plot, lower, upper, color = colorobs, colormap = plot.colormap, + band!(plot, lower, upper, color = colorobs, colormap = plot.colormap, colorscale = plot.colorscale, colorrange = plot.colorrange, inspectable = plot.inspectable) l = lines!(plot, linepoints, color = plot.strokecolor, linestyle = plot.linestyle, linewidth = plot.strokewidth, diff --git a/src/stats/distributions.jl b/src/stats/distributions.jl index 0e219fb0bd9..6f2221119f3 100644 --- a/src/stats/distributions.jl +++ b/src/stats/distributions.jl @@ -113,7 +113,7 @@ maybefit(x, _) = x function convert_arguments(::Type{<:QQPlot}, x′, y; qqline = :none) x = maybefit(x′, y) points, line = fit_qqplot(x, y; qqline = qqline) - return PlotSpec{QQPlot}(points, line) + return PlotSpec(:QQPlot, points, line) end convert_arguments(::Type{<:QQNorm}, y; qqline = :none) = diff --git a/src/stats/ecdf.jl b/src/stats/ecdf.jl index 6494ca58df9..f8038456a9e 100644 --- a/src/stats/ecdf.jl +++ b/src/stats/ecdf.jl @@ -19,11 +19,13 @@ function convert_arguments(P::PlotFunc, ecdf::StatsBase.ECDF; npoints=10_000) end return to_plotspec(ptype, convert_arguments(ptype, x, ecdf(x)); kwargs...) end + function convert_arguments(P::PlotFunc, x::AbstractVector, ecdf::StatsBase.ECDF) ptype = plottype(P, Stairs) kwargs = ptype <: Stairs ? (; step=:post) : NamedTuple() return to_plotspec(ptype, convert_arguments(ptype, x, ecdf(x)); kwargs...) end + function convert_arguments(P::PlotFunc, x0::AbstractInterval, ecdf::StatsBase.ECDF) xmin, xmax = extrema(x0) z = ecdf_xvalues(ecdf, Inf) diff --git a/src/stats/hexbin.jl b/src/stats/hexbin.jl index 47150e0c9e9..c163f29bf0a 100644 --- a/src/stats/hexbin.jl +++ b/src/stats/hexbin.jl @@ -7,10 +7,11 @@ Plots a heatmap with hexagonal bins for the observations `xs` and `ys`. ### Specific to `Hexbin` +- `weights = nothing`: Weights for each observation. Can be `nothing` (each observation carries weight 1) or any `AbstractVector{<: Real}` or `StatsBase.AbstractWeights`. - `bins = 20`: If an `Int`, sets the number of bins in x and y direction. If a `Tuple{Int, Int}`, sets the number of bins for x and y separately. - `cellsize = nothing`: If a `Real`, makes equally-sided hexagons with width `cellsize`. If a `Tuple{Real, Real}` specifies hexagon width and height separately. - `threshold::Int = 1`: The minimal number of observations in the bin to be shown. If 0, all zero-count hexagons fitting into the data limits will be shown. -- `scale = identity`: A function to scale the number of observations in a bin, eg. log10. +- `colorscale = identity`: A function to scale the number of observations in a bin, eg. log10. ### Generic @@ -19,14 +20,18 @@ Plots a heatmap with hexagonal bins for the observations `xs` and `ys`. """ @recipe(Hexbin) do scene return Attributes(; - colormap=theme(scene, :colormap), - colorrange=Makie.automatic, - bins=20, - cellsize=nothing, - threshold=1, - scale=identity, - strokewidth=0, - strokecolor=:black) + colormap=theme(scene, :colormap), + colorscale=identity, + colorrange=Makie.automatic, + lowclip = automatic, + highclip = automatic, + nan_color = :transparent, + bins=20, + weights=nothing, + cellsize=nothing, + threshold=1, + strokewidth=0, + strokecolor=:black) end function spacings_offsets_nbins(bins::Tuple{Int,Int}, cellsize::Nothing, xmi, xma, ymi, yma) @@ -55,7 +60,7 @@ function spacings_offsets_nbins(bins, cellsizes::Tuple{<:Real,<:Real}, xmi, xma, ymi - (resty > 0 ? (yspacing - resty) / 2 : 0), Int(nx) + (restx > 0), Int(ny) + (resty > 0) end -Makie.conversion_trait(::Type{<:Hexbin}) = PointBased() +conversion_trait(::Type{<:Hexbin}) = PointBased() function data_limits(hb::Hexbin) bb = Rect3f(hb.plots[1][1][]) @@ -69,14 +74,18 @@ function data_limits(hb::Hexbin) return Rect3f(no, nw) end -function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) +get_weight(weights, i) = Float64(weights[i]) +get_weight(::StatsBase.UnitWeights, i) = 1e0 +get_weight(::Nothing, i) = 1e0 + +function plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) xy = hb[1] points = Observable(Point2f[]) count_hex = Observable(Float64[]) markersize = Observable(Vec2f(1, 1)) - function calculate_grid(xy, bins, cellsize, threshold, scale) + function calculate_grid(xy, weights, bins, cellsize, threshold) empty!(points[]) empty!(count_hex[]) @@ -93,8 +102,8 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) x_diff = xma - xmi y_diff = yma - ymi - xspacing, yspacing, xoff, yoff, nbinsx, nbinsy = spacings_offsets_nbins(bins, cellsize, xmi, xma, ymi, - yma) + xspacing, yspacing, xoff, yoff, nbinsx, nbinsy = + spacings_offsets_nbins(bins, cellsize, xmi, xma, ymi, yma) ysize = yspacing / 3 * 4 ry = ysize / 2 @@ -102,13 +111,14 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) xsize = xspacing * 2 rx = xsize / sqrt3 - d = Dict{Tuple{Int,Int},Int}() + d = Dict{Tuple{Int,Int}, Float64}() # for the distance measurement, the y dimension must be weighted relative to the x # dimension according to the different sizes in each, otherwise the attribution to hexagonal # cells is wrong yweight = xsize / ysize + i = 1 for (_x, _y) in xy nx, nxs, dvx = nearest_center(_x, xspacing, xoff) ny, nys, dvy = nearest_center(_y, yspacing, yoff) @@ -119,7 +129,7 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) is_grid1 = d1 < d2 # _xy = is_grid1 ? (nx, ny) : (nxs, nys) - + id = if is_grid1 ( cld(dvx, 2), @@ -132,7 +142,8 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) ) end - d[id] = get(d, id, 0) + 1 + d[id] = get(d, id, 0) + (get_weight(weights, i)) + i += 1 end if threshold == 0 @@ -141,9 +152,9 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) for ix in 0:_nx-1 _x = xoff + 2 * ix * xspacing + (isodd(iy) * xspacing) _y = yoff + iy * yspacing - c = get(d, (ix, iy), 0) + c = get(d, (ix, iy), 0.0) push!(points[], Point2f(_x, _y)) - push!(count_hex[], scale(c)) + push!(count_hex[], c) end end else @@ -153,7 +164,7 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) _x = xoff + 2 * ix * xspacing + (isodd(iy) * xspacing) _y = yoff + iy * yspacing push!(points[], Point2f(_x, _y)) - push!(count_hex[], scale(value)) + push!(count_hex[], value) end end end @@ -162,7 +173,8 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) notify(points) return notify(count_hex) end - onany(calculate_grid, xy, hb.bins, hb.cellsize, hb.threshold, hb.scale) + onany(calculate_grid, xy, hb.weights, hb.bins, hb.cellsize, hb.threshold) + # trigger once notify(hb.bins) @@ -187,11 +199,20 @@ function Makie.plot!(hb::Hexbin{<:Tuple{<:AbstractVector{<:Point2}}}) end hexmarker = Polygon(Point2f[(cos(a), sin(a)) for a in range(pi / 6, 13pi / 6; length=7)[1:6]]) - + scale = if haskey(hb, :scale) + @warn("`hexbin(..., scale=$(hb.scale[]))` is deprecated, use `hexbin(..., colorscale=$(hb.scale[]))` instead") + hb.scale + else + hb.colorscale + end return scatter!(hb, points; colorrange=hb.colorrange, color=count_hex, colormap=hb.colormap, + colorscale=scale, + lowclip=hb.lowclip, + highclip=hb.highclip, + nan_color=hb.nan_color, marker=hexmarker, markersize=markersize, markerspace=:data, @@ -212,4 +233,4 @@ function nearest_center(val, spacing, offset) rounded = offset + spacing * (dv + isodd(dv)) rounded_scaled = offset + spacing * (dv + iseven(dv)) return rounded, rounded_scaled, dv -end \ No newline at end of file +end diff --git a/src/stats/hist.jl b/src/stats/hist.jl index bf5cd7e5bf8..165f361bc07 100644 --- a/src/stats/hist.jl +++ b/src/stats/hist.jl @@ -1,10 +1,10 @@ -const histogram_plot_types = [BarPlot, Heatmap, Volume] +const histogram_plot_types = (BarPlot, Heatmap, Volume) function convert_arguments(P::Type{<:AbstractPlot}, h::StatsBase.Histogram{<:Any, N}) where N ptype = plottype(P, histogram_plot_types[N]) f(edges) = edges[1:end-1] .+ diff(edges)./2 kwargs = N == 1 ? (; width = step(h.edges[1]), gap = 0, dodge_gap = 0) : NamedTuple() - to_plotspec(ptype, convert_arguments(ptype, map(f, h.edges)..., Float64.(h.weights)); kwargs...) + return to_plotspec(ptype, convert_arguments(ptype, map(f, h.edges)..., Float64.(h.weights)); kwargs...) end function _hist_center_weights(values, edges, normalization, scale_to, wgts) @@ -78,8 +78,8 @@ function Makie.plot!(plot::StepHist) pop!(attr, :normalization) pop!(attr, :scale_to) pop!(attr, :bins) - # plot the values, not the observables, to be in control of updating - stairs!(plot, points[]; attr..., color=color) + stairs!(plot, points; attr..., color=color) + plot end """ @@ -144,7 +144,7 @@ function pick_hist_edges(vals, bins) if bins isa Int mi, ma = float.(extrema(vals)) if mi == ma - return [mi - 0.5, ma + 0.5] + return (mi - 0.5):(ma + 0.5) end # hist is right-open, so to include the upper data point, make the last bin a tiny bit bigger ma = nextfloat(ma) @@ -187,5 +187,8 @@ function Makie.plot!(plot::Hist) bp[1].val = points[] bp.width = w end + onany(plot, plot.normalization, plot.scale_to, plot.weights) do _, _, _ + bp[1][] = points[] + end plot end diff --git a/src/stats/violin.jl b/src/stats/violin.jl index ef6e6f5ff99..d3d7d5c9707 100644 --- a/src/stats/violin.jl +++ b/src/stats/violin.jl @@ -35,7 +35,7 @@ Draw a violin plot. ) end -conversion_trait(x::Type{<:Violin}) = SampleBased() +conversion_trait(::Type{<:Violin}) = SampleBased() getuniquevalue(v, idxs) = v diff --git a/src/themes/theme_black.jl b/src/themes/theme_black.jl index 1f0b9965b59..3ee6e7fb23d 100644 --- a/src/themes/theme_black.jl +++ b/src/themes/theme_black.jl @@ -16,7 +16,7 @@ function theme_black() ), Legend = ( framecolor = :white, - bgcolor = :black, + backgroundcolor = :black, ), Axis3 = ( xgridcolor = RGBAf(1, 1, 1, 0.16), diff --git a/src/themes/theme_latexfonts.jl b/src/themes/theme_latexfonts.jl new file mode 100644 index 00000000000..e019547959e --- /dev/null +++ b/src/themes/theme_latexfonts.jl @@ -0,0 +1,10 @@ +function theme_latexfonts() + Theme( + fonts = Attributes( + :bold => texfont(:bold), + :bolditalic => texfont(:bolditalic), + :italic => texfont(:italic), + :regular => texfont(:regular) + ) + ) +end diff --git a/src/theming.jl b/src/theming.jl index db0c2afa8a5..5eb76a75567 100644 --- a/src/theming.jl +++ b/src/theming.jl @@ -23,8 +23,6 @@ const DEFAULT_PALETTES = Attributes( side = [:left, :right] ) -Base.@deprecate_binding default_palettes DEFAULT_PALETTES - const MAKIE_DEFAULT_THEME = Attributes( palette = DEFAULT_PALETTES, font = :regular, @@ -34,16 +32,16 @@ const MAKIE_DEFAULT_THEME = Attributes( italic = "TeX Gyre Heros Makie Italic", bold_italic = "TeX Gyre Heros Makie Bold Italic", ), - fontsize = 16, + fontsize = 14, textcolor = :black, padding = Vec3f(0.05), figure_padding = 16, - rowgap = 24, - colgap = 24, + rowgap = 18, + colgap = 18, backgroundcolor = :white, colormap = :viridis, marker = :circle, - markersize = 12, + markersize = 9, markercolor = :black, markerstrokecolor = :black, markerstrokewidth = 0, @@ -53,7 +51,7 @@ const MAKIE_DEFAULT_THEME = Attributes( patchcolor = RGBAf(0, 0, 0, 0.6), patchstrokecolor = :black, patchstrokewidth = 0, - resolution = (800, 600), # 4/3 aspect ratio + size = (600, 450), # 4/3 aspect ratio visible = true, Axis = Attributes(), Axis3 = Attributes(), @@ -68,12 +66,23 @@ const MAKIE_DEFAULT_THEME = Attributes( blur = Int32(2), # A (2blur+1) by (2blur+1) range is used for blurring # N_samples = 64, # number of samples (requires shader reload) ), - ambient = RGBf(0.55, 0.55, 0.55), - lightposition = :eyeposition, inspectable = true, + # Vec is equvalent to 36° right/east, 39° up/north from camera position + # The order here is Vec3f(right of, up from, towards) viewer/camera + light_direction = Vec3f(-0.45679495, -0.6293204, -0.6287243), + camera_relative_light = true, # Only applies to default DirectionalLight + light_color = RGBf(0.5, 0.5, 0.5), + ambient = RGBf(0.45, 0.45, 0.45), + + # Note: this can be set too + # lights = AbstractLight[ + # AmbientLight(RGBf(0.55, 0.55, 0.55)), + # DirectionalLight(RGBf(0.8, 0.8, 0.8), Vec3f(2/3, 2/3, 1/3)) + # ], + CairoMakie = Attributes( - px_per_unit = 1.0, + px_per_unit = 2.0, pt_per_unit = 0.75, antialias = :best, visible = true, @@ -87,6 +96,8 @@ const MAKIE_DEFAULT_THEME = Attributes( vsync = false, render_on_demand = true, framerate = 30.0, + px_per_unit = automatic, + scalefactor = automatic, # GLFW window attributes float = false, @@ -98,19 +109,27 @@ const MAKIE_DEFAULT_THEME = Attributes( monitor = nothing, visible = true, - # Postproccessor + # Shader constants & Postproccessor oit = true, fxaa = true, ssao = false, # This adjusts a factor in the rendering shaders for order independent # transparency. This should be the same for all of them (within one rendering # pipeline) otherwise depth "order" will be broken. - transparency_weight_scale = 1000f0 + transparency_weight_scale = 1000f0, + # maximum number of lights with shading = :verbose + max_lights = 64, + max_light_parameters = 5 * 64 ), WGLMakie = Attributes( framerate = 30.0, - resize_to_body = false + resize_to = nothing, + # DEPRECATED in favor of resize_to + # still needs to be here to gracefully deprecate it + resize_to_body = nothing, + px_per_unit = automatic, + scalefactor = automatic ), RPRMakie = Attributes( @@ -121,9 +140,6 @@ const MAKIE_DEFAULT_THEME = Attributes( ) ) -Base.@deprecate_binding minimal_default MAKIE_DEFAULT_THEME - - const CURRENT_DEFAULT_THEME = deepcopy(MAKIE_DEFAULT_THEME) const THEME_LOCK = Base.ReentrantLock() @@ -144,6 +160,26 @@ function merge_without_obs!(result::Attributes, theme::Attributes) end return result end + +# Same as above, but second argument gets priority so, `merge_without_obs_reverse!(Attributes(a=22), Attributes(a=33)) -> Attributes(a=33)` +function merge_without_obs_reverse!(result::Attributes, priority::Attributes) + result_dict = attributes(result) + for (key, value) in priority + if !haskey(result_dict, key) + result_dict[key] = Observable{Any}(to_value(value)) # the deepcopy part for observables + else + current_value = result[key] + if value isa Attributes && current_value isa Attributes + # if nested attribute, we merge recursively + merge_without_obs_reverse!(current_value, value) + else + result_dict[key] = Observable{Any}(to_value(value)) + end + end + end + return result +end + # Use copy with no obs to quickly deepcopy fast_deepcopy(attributes) = merge_without_obs!(Attributes(), attributes) @@ -177,7 +213,7 @@ restored afterwards, no matter if `f` succeeds or fails. Example: ```julia -my_theme = Theme(resolution = (500, 500), color = :red) +my_theme = Theme(size = (500, 500), color = :red) with_theme(my_theme, color = :blue, linestyle = :dashed) do scatter(randn(100, 2)) end @@ -197,7 +233,8 @@ function with_theme(f, theme = Theme(); kwargs...) end end -theme(::Nothing, key::Symbol) = theme(key) +theme(::Nothing, key::Symbol; default=nothing) = theme(key; default) +theme(::Nothing) = CURRENT_DEFAULT_THEME function theme(key::Symbol; default=nothing) if haskey(CURRENT_DEFAULT_THEME, key) val = to_value(CURRENT_DEFAULT_THEME[key]) diff --git a/src/types.jl b/src/types.jl index c28e74df6a8..a3144183a7b 100644 --- a/src/types.jl +++ b/src/types.jl @@ -1,4 +1,6 @@ abstract type AbstractCamera end +abstract type Block end +abstract type AbstractAxis <: Block end # placeholder if no camera is present struct EmptyCamera <: AbstractCamera end @@ -194,12 +196,23 @@ function Base.empty!(events::Events) return end +abstract type BooleanOperator end + +""" + IsPressedInputType + +Union containing possible input types for `ispressed`. +""" +const IsPressedInputType = Union{Bool,BooleanOperator,Mouse.Button,Keyboard.Button,Set,Vector,Tuple} """ Camera(pixel_area) Struct to hold all relevant matrices and additional parameters, to let backends apply camera based transformations. + +## Fields +$(TYPEDFIELDS) """ struct Camera """ @@ -228,7 +241,12 @@ struct Camera resolution::Observable{Vec2f} """ - Eye position of the camera, sued for e.g. ray tracing. + Focal point of the camera, used for e.g. camera synchronized light direction. + """ + lookat::Observable{Vec3f} + + """ + Eye position of the camera, used for e.g. ray tracing. """ eyeposition::Observable{Vec3f} @@ -237,6 +255,8 @@ struct Camera We need to keep track of them, so, that we can connect and disconnect them. """ steering_nodes::Vector{ObserverFunction} + + calculated_values::Dict{Symbol, Observable} end """ @@ -250,41 +270,48 @@ struct Transformation <: Transformable scale::Observable{Vec3f} rotation::Observable{Quaternionf} model::Observable{Mat4f} + parent_model::Observable{Mat4f} # data conversion observable, for e.g. log / log10 etc transform_func::Observable{Any} - function Transformation(translation, scale, rotation, model, transform_func) - return new( - RefValue{Transformation}(), - translation, scale, rotation, model, transform_func - ) + function Transformation(translation, scale, rotation, transform_func) + translation_o = convert(Observable{Vec3f}, translation) + scale_o = convert(Observable{Vec3f}, scale) + rotation_o = convert(Observable{Quaternionf}, rotation) + parent_model = Observable(Mat4f(I)) + model = map(translation_o, scale_o, rotation_o, parent_model) do t, s, r, p + return p * transformationmatrix(t, s, r) + end + transform_func_o = convert(Observable{Any}, transform_func) + return new(RefValue{Transformation}(), + translation_o, scale_o, rotation_o, model, parent_model, transform_func_o) end end -""" -`PlotSpec{P<:AbstractPlot}(args...; kwargs...)` - -Object encoding positional arguments (`args`), a `NamedTuple` of attributes (`kwargs`) -as well as plot type `P` of a basic plot. -""" -struct PlotSpec{P<:AbstractPlot} - args::Tuple - kwargs::NamedTuple - PlotSpec{P}(args...; kwargs...) where {P<:AbstractPlot} = new{P}(args, values(kwargs)) +function Transformation(transform_func=identity; + scale=Vec3f(1), + translation=Vec3f(0), + rotation=Quaternionf(0, 0, 0, 1)) + return Transformation(translation, + scale, + rotation, + transform_func) end -PlotSpec(args...; kwargs...) = PlotSpec{Combined{Any}}(args...; kwargs...) - -Base.getindex(p::PlotSpec, i::Int) = getindex(p.args, i) -Base.getindex(p::PlotSpec, i::Symbol) = getproperty(p.kwargs, i) - -to_plotspec(::Type{P}, args; kwargs...) where {P} = - PlotSpec{P}(args...; kwargs...) - -to_plotspec(::Type{P}, p::PlotSpec{S}; kwargs...) where {P, S} = - PlotSpec{plottype(P, S)}(p.args...; p.kwargs..., kwargs...) - -plottype(::PlotSpec{P}) where {P} = P - +function Transformation(parent::Transformable; + scale=Vec3f(1), + translation=Vec3f(0), + rotation=Quaternionf(0, 0, 0, 1), + transform_func=nothing) + connect_func = isnothing(transform_func) + trans = isnothing(transform_func) ? identity : transform_func + + trans = Transformation(translation, + scale, + rotation, + trans) + connect!(transformation(parent), trans; connect_func=connect_func) + return trans +end struct ScalarOrVector{T} sv::Union{T, Vector{T}} @@ -378,3 +405,59 @@ 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} <: Function + """ + 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 + name::Symbol + function ReversibleScale(forward, inverse = Automatic(); limits = (0f0, 10f0), interval = (-Inf32, Inf32), name=Symbol(forward)) + 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))")) + + return new{typeof(forward),typeof(inverse),typeof(interval)}(forward, inverse, limits, interval, name) + end +end + +(s::ReversibleScale)(args...) = s.forward(args...) # functor +Base.show(io::IO, s::ReversibleScale) = print(io, "ReversibleScale($(s.name))") +Base.show(io::IO, ::MIME"text/plain", s::ReversibleScale) = print(io, "ReversibleScale($(s.name))") + + +struct Cycler + counters::IdDict{Type,Int} +end + +Cycler() = Cycler(IdDict{Type,Int}()) diff --git a/src/units.jl b/src/units.jl index d93d71f8331..7105410199e 100644 --- a/src/units.jl +++ b/src/units.jl @@ -18,7 +18,7 @@ ######### function to_screen(scene::Scene, mpos) - return Point2f(mpos) .- Point2f(minimum(pixelarea(scene)[])) + return Point2f(mpos) .- Point2f(minimum(viewport(scene)[])) end number(x::Unit) = x.value diff --git a/src/utilities/quaternions.jl b/src/utilities/quaternions.jl index bcae57f50de..f5ccf832bc3 100644 --- a/src/utilities/quaternions.jl +++ b/src/utilities/quaternions.jl @@ -81,6 +81,17 @@ function Base.:(*)(quat::Quaternion{T}, vec::P) where {T, P <: StaticVector{3}} (num8 - num11) * vec[1] + (num9 + num10) * vec[2] + (1f0 - (num4 + num5)) * vec[3] ) end + +function Base.:(*)(quat::Quaternion, bb::Rect3{T}) where {T} + points = corners(bb) + first = points[1] + bb = Ref(Rect3{T}(quat * first, zero(first))) + for i in 2:length(points) + bb[] = _update_rect(bb[], Point3{T}(quat * points[i])) + end + return bb[] +end + Base.conj(q::Quaternion) = Quaternion(-q[1], -q[2], -q[3], q[4]) function Base.:(*)(q::Quaternion, w::Quaternion) diff --git a/src/utilities/texture_atlas.jl b/src/utilities/texture_atlas.jl index bc413a87273..47907aeffe2 100644 --- a/src/utilities/texture_atlas.jl +++ b/src/utilities/texture_atlas.jl @@ -1,3 +1,4 @@ +const SERIALIZATION_FORMAT_VERSION = "v6" struct TextureAtlas rectangle_packer::RectanglePacker{Int32} @@ -70,12 +71,10 @@ function Base.show(io::IO, atlas::TextureAtlas) println(io, " font_render_callback: ", length(atlas.font_render_callback)) end -const SERIALIZATION_FORMAT_VERSION = "v2" - # basically a singleton for the textureatlas function get_cache_path(resolution::Int, pix_per_glyph::Int) path = abspath( - first(Base.DEPOT_PATH), "makie", + makie_cache_dir, "$(SERIALIZATION_FORMAT_VERSION)_texture_atlas_$(resolution)_$(pix_per_glyph).bin" ) if !ispath(dirname(path)) @@ -158,7 +157,7 @@ function get_texture_atlas(resolution::Int = 2048, pix_per_glyph::Int = 64) end end -const CACHE_DOWNLOAD_URL = "https://github.com/MakieOrg/Makie.jl/releases/download/v0.19.0/" +const CACHE_DOWNLOAD_URL = "https://github.com/MakieOrg/Makie.jl/releases/download/v0.20.0/" function cached_load(resolution::Int, pix_per_glyph::Int) path = get_cache_path(resolution, pix_per_glyph) @@ -188,8 +187,6 @@ end const DEFAULT_FONT = NativeFont[] const ALTERNATIVE_FONTS = NativeFont[] const FONT_LOCK = Base.ReentrantLock() -Base.@deprecate_binding _default_font DEFAULT_FONT -Base.@deprecate_binding _alternative_fonts ALTERNATIVE_FONTS function defaultfont() lock(FONT_LOCK) do @@ -220,9 +217,8 @@ function alternativefonts() end function render_default_glyphs!(atlas) - font = defaultfont() - chars = ['a':'z'..., 'A':'Z'..., '0':'9'..., '.', '-'] - fonts = to_font.(to_value.(values(Makie.MAKIE_DEFAULT_THEME.fonts))) + chars = ['a':'z'..., 'A':'Z'..., '0':'9'..., '.', '-', MINUS_SIGN] + fonts = map(x-> to_font(to_value(x)), values(MAKIE_DEFAULT_THEME.fonts)) for font in fonts for c in chars insert_glyph!(atlas, c, font) @@ -292,17 +288,26 @@ function glyph_uv_width!(atlas::TextureAtlas, b::BezierPath) return atlas.uv_rectangles[glyph_index!(atlas, b)] end + +# Seems like StableHashTraits is so slow, that it's worthwhile to memoize the hashes +const MEMOIZED_HASHES = Dict{Any, UInt32}() + +function fast_stable_hash(x) + return get!(MEMOIZED_HASHES, x) do + return StableHashTraits.stable_hash(x; alg=crc32c, version=2) + end +end + function insert_glyph!(atlas::TextureAtlas, glyph, font::NativeFont) glyphindex = FreeTypeAbstraction.glyph_index(font, glyph) - hash = StableHashTraits.stable_hash((glyphindex, FreeTypeAbstraction.fontname(font))) + hash = fast_stable_hash((glyphindex, FreeTypeAbstraction.fontname(font))) return insert_glyph!(atlas, hash, (glyphindex, font)) end function insert_glyph!(atlas::TextureAtlas, path::BezierPath) - return insert_glyph!(atlas, StableHashTraits.stable_hash(path), path) + return insert_glyph!(atlas, fast_stable_hash(path), path) end - function insert_glyph!(atlas::TextureAtlas, hash::UInt32, path_or_glyp::Union{BezierPath, Tuple{UInt64, NativeFont}}) return get!(atlas.mapping, hash) do uv_pixel = render(atlas, path_or_glyp) @@ -433,6 +438,7 @@ function marker_to_sdf_shape(arr::AbstractVector) shape1 = marker_to_sdf_shape(first(arr)) for elem in arr shape2 = marker_to_sdf_shape(elem) + shape2 isa Shape && shape1 isa Shape && continue shape1 !== shape2 && error("Can't use an array of markers that require different primitive_shapes $(typeof.(arr)).") end return shape1 @@ -481,12 +487,15 @@ function bezierpath_pad_scale_factor(atlas::TextureAtlas, bp) full_pixel_size_in_atlas = uv_width * Vec2f(size(atlas)) # left + right pad - cutoff from pixel centering full_pad = 2f0 * atlas.glyph_padding - 1 - return full_pad ./ (full_pixel_size_in_atlas .- full_pad) + # size without padding + unpadded_pixel_size = full_pixel_size_in_atlas .- full_pad + # See offset_bezierpath + return full_pixel_size_in_atlas ./ maximum(unpadded_pixel_size) end function marker_scale_factor(atlas::TextureAtlas, path::BezierPath) - # padded_width = (unpadded_target_width + unpadded_target_width * pad_per_unit) - return (1f0 .+ bezierpath_pad_scale_factor(atlas, path)) .* widths(Makie.bbox(path)) + # See offset_bezierpath + return bezierpath_pad_scale_factor(atlas, path) * maximum(widths(bbox(path))) end function rescale_marker(atlas::TextureAtlas, pathmarker::BezierPath, font, markersize) @@ -510,9 +519,28 @@ function rescale_marker(atlas::TextureAtlas, char::Char, font, markersize) end function offset_bezierpath(atlas::TextureAtlas, bp::BezierPath, markersize::Vec2, markeroffset::Vec2) + # - wh = widths(bbox(bp)) is the untouched size of the given bezierpath + # - full_pixel_size_in_atlas is the size of the signed distance field in the + # texture atlas. This includes glyph padding + # - px_size is the size of signed distance field without padding + # To correct scaling on glow, stroke and AA widths in GLMakie we need to + # keep the aspect ratio of the aspect ratio (somewhat) correct when + # generating the sdf. This results in direct proportionality only for the + # longer dimension of wh and px_size. The shorter side becomes inaccurate + # due to integer rounding issues. + # 1. To calculate the width we can use the ratio of the proportional sides + # scale = maximum(wh) / maximum(px_size) + # to scale the padded_size we need to display + # scale * full_pixel_size_in_atlas + # (Part of this is moved to bezierpath_pad_scale_factor) + # 2. To calculate the offset we can simple move to the center of the bezier + # path and consider that the center of the final marker. (From the center + # scaling should be equal in ±x and ±y direction respectively.) + bb = bbox(bp) - pad_offset = origin(bb) .- 0.5f0 .* bezierpath_pad_scale_factor(atlas, bp) .* widths(bb) - return markersize .* pad_offset + scaled_size = bezierpath_pad_scale_factor(atlas, bp) * maximum(widths(bb)) + return markersize * (origin(bb) .+ 0.5f0 * widths(bb) .- 0.5f0 .* scaled_size) + end function offset_bezierpath(atlas::TextureAtlas, bp, scale, offset) @@ -529,10 +557,11 @@ end offset_marker(atlas, marker, font, markersize, markeroffset) = markeroffset -function marker_attributes(atlas::TextureAtlas, marker, markersize, font, marker_offset) +function marker_attributes(atlas::TextureAtlas, marker, markersize, font, marker_offset, plot_object) atlas_obs = Observable(atlas) # for map to work - scale = map(rescale_marker, atlas_obs, marker, font, markersize; ignore_equal_values=true) - quad_offset = map(offset_marker, atlas_obs, marker, font, markersize, marker_offset; ignore_equal_values=true) + scale = map(rescale_marker, plot_object, atlas_obs, marker, font, markersize; ignore_equal_values=true) + quad_offset = map(offset_marker, plot_object, atlas_obs, marker, font, markersize, marker_offset; + ignore_equal_values=true) return scale, quad_offset end diff --git a/src/utilities/utilities.jl b/src/utilities/utilities.jl index 3be79b92cd3..450b2125854 100644 --- a/src/utilities/utilities.jl +++ b/src/utilities/utilities.jl @@ -35,23 +35,6 @@ function resample_cmap(cmap, ncolors::Integer; alpha=1.0) end end -""" - resampled_colors(attributes::Attributes, levels::Integer) - -Resample the color attribute from `attributes`. Resamples `:colormap` if present, -or repeats `:color`. -""" -function resampled_colors(attributes, levels::Integer) - cols = if haskey(attributes, :color) - c = get_attribute(attributes, :color) - c isa AbstractVector ? resample(c, levels) : repeated(c, levels) - else - c = get_attribute(attributes, :colormap) - resample(c, levels) - end -end - - """ Like `get!(f, dict, key)` but also calls `f` and replaces `key` when the corresponding value is nothing @@ -106,7 +89,19 @@ function extract_expr(extract_func, dictlike, args) end """ -usage @extract scene (a, b, c, d) + @extract scene (a, b, c, d) + +This becomes + +```julia +begin + a = scene[:a] + b = scene[:b] + c = scene[:d] + d = scene[:d] + (a, b, c, d) +end +``` """ macro extract(scene, args) extract_expr(getindex, scene, args) @@ -169,20 +164,22 @@ end attr_broadcast_length(x::NativeFont) = 1 attr_broadcast_length(x::VecTypes) = 1 # these are our rules, and for what we do, Vecs are usually scalars -attr_broadcast_length(x::AbstractArray) = length(x) +attr_broadcast_length(x::AbstractVector) = length(x) attr_broadcast_length(x::AbstractPattern) = 1 attr_broadcast_length(x) = 1 attr_broadcast_length(x::ScalarOrVector) = x.sv isa Vector ? length(x.sv) : 1 attr_broadcast_getindex(x::NativeFont, i) = x attr_broadcast_getindex(x::VecTypes, i) = x # these are our rules, and for what we do, Vecs are usually scalars -attr_broadcast_getindex(x::AbstractArray, i) = x[i] +attr_broadcast_getindex(x::AbstractVector, i) = x[i] +attr_broadcast_getindex(x::AbstractArray{T, 0}, i) where T = x[1] attr_broadcast_getindex(x::AbstractPattern, i) = x attr_broadcast_getindex(x, i) = x attr_broadcast_getindex(x::Ref, i) = x[] # unwrap Refs just like in normal broadcasting, for protecting iterables attr_broadcast_getindex(x::ScalarOrVector, i) = x.sv isa Vector ? x.sv[i] : x.sv -is_vector_attribute(x::AbstractArray) = true +is_vector_attribute(x::AbstractVector) = true +is_vector_attribute(x::Base.Generator) = is_vector_attribute(x.iter) is_vector_attribute(x::NativeFont) = false is_vector_attribute(x::Quaternion) = false is_vector_attribute(x::VecTypes) = false @@ -265,6 +262,22 @@ function merged_get!(defaults::Function, key, scene::SceneLike, input::Attribute return merge!(input, d) end +function Base.replace!(target::Attributes, key, scene::SceneLike, overwrite::Attributes) + if haskey(theme(scene), key) + _replace!(target, theme(scene, key)) + end + return _replace!(target, overwrite) +end + +function _replace!(target::Attributes, overwrite::Attributes) + for k in keys(target) + haskey(overwrite, k) && (target[k] = overwrite[k]) + end + return +end + + + to_vector(x::AbstractVector, len, T) = convert(Vector{T}, x) function to_vector(x::AbstractArray, len, T) if length(x) in size(x) # assert that just one dim != 1 @@ -278,23 +291,6 @@ function to_vector(x::ClosedInterval, len, T) range(a, stop=b, length=len) end -""" -A colorsampler maps numnber values from a certain range to values of a colormap -``` -x = ColorSampler(colormap, (0.0, 1.0)) -x[0.5] # returns color at half point of colormap -``` -""" -struct ColorSampler{Data <: AbstractArray} - colormap::Data - color_range::Tuple{Float64,Float64} -end - -function Base.getindex(cs::ColorSampler, value::Number) - return interpolated_getindex(cs.colormap, value, cs.color_range) -end - - # This function was copied from GR.jl, # written by Josef Heinen. """ @@ -336,6 +332,80 @@ function surface_normals(x, y, z) return vec(map(normal, CartesianIndices(z))) end + +############################################################ +# NaN-aware normal & mesh handling # +############################################################ + +""" + nan_aware_orthogonal_vector(v1, v2, v3) where N + +Returns an un-normalized normal vector for the triangle formed by the three input points. +Skips any combination of the inputs for which any point has a NaN component. +""" +function nan_aware_orthogonal_vector(v1, v2, v3) + (isnan(v1) || isnan(v2) || isnan(v3)) && return Vec3f(0) + return Vec3f(cross(v2 - v1, v3 - v1)) +end + +""" + nan_aware_normals(vertices::AbstractVector{<: Union{Point, PointMeta}}, faces::AbstractVector{F}) + +Computes the normals of a mesh defined by `vertices` and `faces` (a vector of `GeometryBasics.NgonFace`) +which ignores all contributions from points with `NaN` components. + +Equivalent in application to `GeometryBasics.normals`. +""" +function nan_aware_normals(vertices::AbstractVector{<:AbstractPoint{3,T}}, faces::AbstractVector{F}) where {T,F<:NgonFace} + normals_result = zeros(Vec3f, length(vertices)) + free_verts = GeometryBasics.metafree.(vertices) + + for face in faces + + v1, v2, v3 = free_verts[face] + # we can get away with two edges since faces are planar. + n = nan_aware_orthogonal_vector(v1, v2, v3) + + for i in 1:length(F) + fi = face[i] + normals_result[fi] = normals_result[fi] + n + end + end + normals_result .= GeometryBasics.normalize.(normals_result) + return normals_result +end + +function nan_aware_normals(vertices::AbstractVector{<:AbstractPoint{2,T}}, faces::AbstractVector{F}) where {T,F<:NgonFace} + return Vec2f.(nan_aware_normals(map(v -> Point3{T}(v..., 0), vertices), faces)) +end + + +function nan_aware_normals(vertices::AbstractVector{<:GeometryBasics.PointMeta{D,T}}, faces::AbstractVector{F}) where {D,T,F<:NgonFace} + return nan_aware_normals(collect(GeometryBasics.metafree.(vertices)), faces) +end + +function surface2mesh(xs, ys, zs::AbstractMatrix, transform_func = identity, space = :data) + # crate a `Matrix{Point3}` + # ps = matrix_grid(identity, xs, ys, zs) + ps = matrix_grid(p -> apply_transform(transform_func, p, space), xs, ys, zs) + # create valid tessellations (triangulations) for the mesh + # knowing that it is a regular grid makes this simple + rect = Tesselation(Rect2f(0, 0, 1, 1), size(zs)) + # we use quad faces so that color handling is consistent + faces = decompose(QuadFace{Int}, rect) + # and remove quads that contain a NaN coordinate to avoid drawing triangles + faces = filter(f -> !any(i -> isnan(ps[i]), f), faces) + # create the uv (texture) vectors + uv = map(x-> Vec2f(1f0 - x[2], 1f0 - x[1]), decompose_uv(rect)) + # return a mesh with known uvs and normals. + return GeometryBasics.Mesh(GeometryBasics.meta(ps; uv=uv, normals = nan_aware_normals(ps, faces)), faces, ) +end + + +############################################################ +# Matrix grid method for surface handling # +############################################################ + """ matrix_grid(f, x::AbstractArray, y::AbstractArray, z::AbstractMatrix)::Vector{Point3f} @@ -353,6 +423,10 @@ function matrix_grid(f, x::ClosedInterval, y::ClosedInterval, z::AbstractMatrix) matrix_grid(f, LinRange(extrema(x)..., size(z, 1)), LinRange(extrema(x)..., size(z, 2)), z) end +############################################################ +# Attribute key extraction # +############################################################ + function extract_keys(attributes, keys) attr = Attributes() for key in keys @@ -362,5 +436,37 @@ function extract_keys(attributes, keys) end # Scalar - Vector getindex -sv_getindex(v::Vector, i::Integer) = v[i] -sv_getindex(x, i::Integer) = x +sv_getindex(v::AbstractVector, i::Integer) = v[i] +sv_getindex(x, ::Integer) = x +sv_getindex(x::VecTypes, ::Integer) = x + +# TODO: move to GeometryBasics +function corners(rect::Rect2{T}) where T + o = minimum(rect) + w = widths(rect) + T0 = zero(T) + return Point{3,T}[o .+ Vec2{T}(x, y) for x in (T0, w[1]) for y in (T0, w[2])] +end + +function corners(rect::Rect3{T}) where T + o = minimum(rect) + w = widths(rect) + T0 = zero(T) + return Point{3,T}[o .+ Vec3{T}(x, y, z) for x in (T0, w[1]) for y in (T0, w[2]) for z in (T0, w[3])] +end + +""" + available_plotting_methods() + +Returns an array of all available plotting functions. +""" +function available_plotting_methods() + meths = [] + for m1 in methods(Makie.default_theme) + params = m1.sig.parameters + if length(params) == 3 && params[3] isa UnionAll + push!(meths, Makie.plotfunc(params[3].var.ub)) + end + end + return meths +end diff --git a/test/PolarAxis.jl b/test/PolarAxis.jl new file mode 100644 index 00000000000..8029e1835f1 --- /dev/null +++ b/test/PolarAxis.jl @@ -0,0 +1,152 @@ +@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[] == (:origin, 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[] == (:origin, 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 Offset" begin + fig = Figure() + ax = PolarAxis(fig[1, 1], radius_at_origin = -1.0, rlimits = (0, 10)) + @test ax.scene.transformation.transform_func[].r0 == -1.0 + end +end \ No newline at end of file diff --git a/test/barplot.jl b/test/barplot.jl new file mode 100644 index 00000000000..2a1e6bf9a79 --- /dev/null +++ b/test/barplot.jl @@ -0,0 +1,130 @@ +@testset "Barplot" begin + @testset "label align" begin + @testset "automatic" begin + # for more info see https://github.com/MakieOrg/Makie.jl/issues/3160 + # below is the best square angles behavior for bar labels + + al = Makie.automatic + + y_dir, flip = false, false + @test Makie.calculate_bar_label_align(al, 0.0, y_dir, flip) ≈ Vec2f(0.0, 0.5) + @test Makie.calculate_bar_label_align(al, π, y_dir, flip) ≈ Vec2f(1.0, 0.5) + @test Makie.calculate_bar_label_align(al, π/2, y_dir, flip) ≈ Vec2f(0.5, 1.0) + @test Makie.calculate_bar_label_align(al, -π/2, y_dir, flip) ≈ Vec2f(0.5, 0.0) + + y_dir, flip = true, false + @test Makie.calculate_bar_label_align(al, 0.0, y_dir, flip) ≈ Vec2f(0.5, 0.0) + @test Makie.calculate_bar_label_align(al, π, y_dir, flip) ≈ Vec2f(0.5, 1.0) + @test Makie.calculate_bar_label_align(al, π/2, y_dir, flip) ≈ Vec2f(0.0, 0.5) + @test Makie.calculate_bar_label_align(al, -π/2, y_dir, flip) ≈ Vec2f(1.0, 0.5) + + y_dir, flip = false, true + @test Makie.calculate_bar_label_align(al, 0.0, y_dir, flip) ≈ Vec2f(1.0, 0.5) + @test Makie.calculate_bar_label_align(al, π, y_dir, flip) ≈ Vec2f(0.0, 0.5) + @test Makie.calculate_bar_label_align(al, π/2, y_dir, flip) ≈ Vec2f(0.5, 0.0) + @test Makie.calculate_bar_label_align(al, -π/2, y_dir, flip) ≈ Vec2f(0.5, 1.0) + + y_dir, flip = true, true + @test Makie.calculate_bar_label_align(al, 0.0, y_dir, flip) ≈ Vec2f(0.5, 1.0) + @test Makie.calculate_bar_label_align(al, π, y_dir, flip) ≈ Vec2f(0.5, 0.0) + @test Makie.calculate_bar_label_align(al, π/2, y_dir, flip) ≈ Vec2f(1.0, 0.5) + @test Makie.calculate_bar_label_align(al, -π/2, y_dir, flip) ≈ Vec2f(0.0, 0.5) + end + + @testset "manual" begin + input = 0.0, false, false + for align in (Vec2f(1.0, 0.5), Point2f(1.0, 0.5), (1.0, 0.5), (1, 0), (1.0, 0)) + @test Makie.calculate_bar_label_align(align, input...) ≈ Vec2f(align) + end + end + + @testset "symbols" begin + input = 0.0, false, false + @test Makie.calculate_bar_label_align((:center, :center), input...) ≈ Makie.calculate_bar_label_align((0.5, 0.5), input...) + end + + @testset "error" begin + input = 0.0, false, false + for align in ("center", 0.5, ("center", "center")) + @test_throws ErrorException Makie.calculate_bar_label_align(align, input...) + end + end + end + + @testset "stack" begin + x1 = [1, 1, 1, 1] + grp_dodge1 = [2, 2, 1, 1] + grp_stack1 = [1, 2, 1, 2] + y1 = [2, 3, -3, -2] + + x2 = [2, 2, 2, 2] + grp_dodge2 = [3, 4, 3, 4] + grp_stack2 = [3, 4, 3, 4] + y2 = [2, 3, -3, -2] + + from, to = Makie.stack_grouped_from_to(grp_stack1, y1, (; x1 = x1, grp_dodge1 = grp_dodge1)) + from1 = [0.0, 2.0, 0.0, -3.0] + to1 = [2.0, 5.0, -3.0, -5.0] + @test from == from1 + @test to == to1 + + from, to = Makie.stack_grouped_from_to(grp_stack2, y2, (; x2 = x2, grp_dodge2 = grp_dodge2)) + from2 = [0.0, 0.0, 0.0, 0.0] + to2 = [2.0, 3.0, -3.0, -2.0] + @test from == from2 + @test to == to2 + + perm = [1, 4, 2, 7, 5, 3, 8, 6] + x = [x1; x2][perm] + y = [y1; y2][perm] + grp_dodge = [grp_dodge1; grp_dodge2][perm] + grp_stack = [grp_stack1; grp_stack2][perm] + + from_test = [from1; from2][perm] + to_test = [to1; to2][perm] + + from, to = Makie.stack_grouped_from_to(grp_stack, y, (; x = x, grp_dodge = grp_dodge)) + @test from == from_test + @test to == to_test + end + + @testset "zero-height" begin + grp_stack = [1, 2, 1, 2] + x = [1, 1, 2, 2] + + y = [1.0, 0.0, -1.0, -1.0] + from = [0.0, 1.0, 0.0, -1.0] + to = [1.0, 1.0, -1.0, -2.0] + from_, to_ = Makie.stack_grouped_from_to(grp_stack, y, (; x)) + @test from == from_ + @test to == to_ + + y = [-1.0, 0.0, -1.0, -1.0] + from = [0.0, -1.0, 0.0, -1.0] + to = [-1.0, -1.0, -1.0, -2.0] + from_, to_ = Makie.stack_grouped_from_to(grp_stack, y, (; x)) + @test from == from_ + @test to == to_ + + y = [0.0, 1.0, -1.0, -1.0] + from = [0.0, 0.0, 0.0, -1.0] + to = [0.0, 1.0, -1.0, -2.0] + from_, to_ = Makie.stack_grouped_from_to(grp_stack, y, (; x)) + @test from == from_ + @test to == to_ + + y = [0.0, -1.0, -1.0, -1.0] + from = [0.0, 0.0, 0.0, -1.0] + to = [0.0, -1.0, -1.0, -2.0] + from_, to_ = Makie.stack_grouped_from_to(grp_stack, y, (; x)) + @test from == from_ + @test to == to_ + + y = [0.0, 1.0, -1.0, -1.0] + from = [0.0, 0.0, 0.0, -1.0] + to = [0.0, 1.0, -1.0, -2.0] + from_, to_ = Makie.stack_grouped_from_to(1:4, y, (; x=ones(4))) + @test from == from_ + @test to == to_ + end +end diff --git a/test/bezier.jl b/test/bezier.jl new file mode 100644 index 00000000000..2111b8f235e --- /dev/null +++ b/test/bezier.jl @@ -0,0 +1,10 @@ +using Makie, Test + +# nice reference: https://www.nan.fyi/svg-paths +@testset "BezierPath construction" begin + @test_nowarn BezierPath("m 0,9 L 0,5138 0,9 z") + @test_broken BezierPath("m 0,1e-5 L 0,5138 0,9 z") isa BezierPath + @test_nowarn BezierPath("M 100,100 C 100,200 200,100 200,200 z") + @test_broken BezierPath("M 100,100 Q 50,150,100,100 z") isa BezierPath + @test_broken BezierPath("M 3.0 10.0 A 10.0 7.5 0.0 0.0 0.0 20.0 15.0 z") isa BezierPath +end diff --git a/test/boundingboxes.jl b/test/boundingboxes.jl index 266548725d8..5008cf1fe5c 100644 --- a/test/boundingboxes.jl +++ b/test/boundingboxes.jl @@ -1,3 +1,39 @@ +function Base.isapprox(r1::Rect{D}, r2::Rect{D}; kwargs...) where D + return isapprox(minimum(r1), minimum(r2); kwargs...) && + isapprox(widths(r1), widths(r2); kwargs...) +end + +@testset "data_limits(plot)" begin + ps = Point2f[(0, 0), (1, 1)] + + fig, ax, p = hexbin(ps) + ms = to_ndim(Vec3f, Vec2f(p.plots[1].markersize[]), 0) + @test data_limits(p) ≈ Rect3f(-ms, Vec3f(1, 1, 0) .+ 2ms) + + fig, ax, p = errorbars(ps, [0.5, 0.5]) + @test data_limits(p) ≈ Rect3f(-Point3f(0, 0.5, 0), Vec3f(1, 2, 0)) + + fig, ax, p = bracket(ps...) + @test data_limits(p) ≈ Rect3f(Point3f(0), Vec3f(1, 1, 0)) + + fig = Figure() + ax = Axis(fig[1, 1], yscale=log, xscale=log) + scatter!(ax, [0.5, 1, 2], [0.5, 1, 2]) + p1 = vlines!(ax, [0.5]) + p2 = hlines!(ax, [0.5]) + p3 = vspan!(ax, [0.25], [0.75]) + p4 = hspan!(ax, [0.25], [0.75]) + Makie.reset_limits!(ax) + + lims = ax.finallimits[] + x, y = minimum(lims); w, h = widths(lims) + + @test data_limits(p1) ≈ Rect3f(Point3f(0.5, y, 0), Vec3f(0, h, 0)) + @test data_limits(p2) ≈ Rect3f(Point3f(x, 0.5, 0), Vec3f(w, 0, 0)) + @test data_limits(p3) ≈ Rect3f(Point3f(0.25, y, 0), Vec3f(0.5, h, 0)) + @test data_limits(p4) ≈ Rect3f(Point3f(x, 0.25, 0), Vec3f(w, 0.5, 0)) +end + @testset "boundingbox(plot)" begin cat = FileIO.load(Makie.assetpath("cat.obj")) @@ -10,13 +46,24 @@ fig, ax, p = surface([x*y for x in 1:10, y in 1:10]) bb = boundingbox(p) - @test bb.origin ≈ Point3f(0.0, 0.0, 1.0) - @test bb.widths ≈ Vec3f(10.0, 10.0, 99.0) + @test bb.origin ≈ Point3f(1.0, 1.0, 1.0) + @test bb.widths ≈ Vec3f(9.0, 9.0, 99.0) fig, ax, p = meshscatter([Point3f(x, y, z) for x in 1:5 for y in 1:5 for z in 1:5]) bb = boundingbox(p) - @test bb.origin ≈ Point3f(1) - @test bb.widths ≈ Vec3f(4) + # Note: awkwards numbers come from using mesh over Sphere + @test bb.origin ≈ Point3f(0.9011624, 0.9004657, 0.9) + @test bb.widths ≈ Vec3f(4.1986046, 4.199068, 4.2) + + fig, ax, p = meshscatter( + [Point3f(0) for _ in 1:3], + marker = Rect3f(Point3f(-0.1, -0.1, -0.1), Vec3f(0.2, 0.2, 1.2)), + markersize = Vec3f(1, 1, 2), + rotations = Makie.rotation_between.((Vec3f(0,0,1),), Vec3f[(1,0,0), (0,1,0), (0,0,1)]) + ) + bb = boundingbox(p) + @test bb.origin ≈ Point3f(-0.2) + @test bb.widths ≈ Vec3f(2.4) fig, ax, p = volume(rand(5, 5, 5)) bb = boundingbox(p) @@ -42,17 +89,26 @@ bb = boundingbox(p) @test bb.origin ≈ Point3f(0.5, 0.5, 0) @test bb.widths ≈ Vec3f(10.0, 10.0, 0) - + fig, ax, p = image(rand(10, 10)) bb = boundingbox(p) @test bb.origin ≈ Point3f(0) @test bb.widths ≈ Vec3f(10.0, 10.0, 0) # text transforms to pixel space atm (TODO) - fig = Figure(resolution = (400, 400)) + fig = Figure(size = (400, 400)) ax = Axis(fig[1, 1]) p = text!(ax, Point2f(10), text = "test", fontsize = 20) bb = boundingbox(p) - @test bb.origin ≈ Point3f(340, 341, 0) + @test bb.origin ≈ Point3f(343.0, 345.0, 0) @test bb.widths ≈ Vec3f(32.24, 23.3, 0) -end \ No newline at end of file +end + +@testset "invalid contour bounding box" begin + a = b = 1:3 + levels = collect(1:3) + c = [0 1 2; 1 2 3; 4 5 NaN] + contour(a, b, c; levels, labels = true) + c = [0 1 2; 1 2 3; 4 5 Inf] + contour(a, b, c; levels, labels = true) +end diff --git a/test/conversions.jl b/test/conversions.jl index 881062c02c3..be31c61cc7c 100644 --- a/test/conversions.jl +++ b/test/conversions.jl @@ -38,7 +38,6 @@ end X4 = rand(2,10) V4 = to_vertices(X4) @test Float32(X4[1,7]) == V4[7][1] - @test V4[7][3] == 0 X5 = rand(3,10) V5 = to_vertices(X5) @@ -47,7 +46,6 @@ end X6 = rand(10,2) V6 = to_vertices(X6) @test Float32(X6[7,1]) == V6[7][1] - @test V6[7][3] == 0 X7 = rand(10,3) V7 = to_vertices(X7) @@ -117,8 +115,7 @@ end @testset "functions" begin x = -pi..pi - s = convert_arguments(Lines, x, sin) - xy = s.args[1] + (xy,) = convert_arguments(Lines, x, sin) @test xy[1][1] ≈ -pi @test xy[end][1] ≈ pi for (val, fval) in xy @@ -126,8 +123,7 @@ end end x = range(-pi, stop=pi, length=100) - s = convert_arguments(Lines, x, sin) - xy = s.args[1] + (xy,) = convert_arguments(Lines, x, sin) @test xy[1][1] ≈ -pi @test xy[end][1] ≈ pi for (val, fval) in xy @@ -278,12 +274,173 @@ end @testset "empty poly" begin + # Geometry Primitive f, ax, pl = poly(Rect2f[]); pl[1] = [Rect2f(0, 0, 1, 1)]; @test pl.plots[1][1][] == [GeometryBasics.triangle_mesh(Rect2f(0, 0, 1, 1))] - f, ax, pl = poly(Vector{Point2f}[]) + # Empty Polygon + f, ax, pl = poly(Polygon(Point2f[])); + pl[1] = Polygon(Point2f[(1,0), (1,1), (0,1)]); + @test pl.plots[1][1][] == GeometryBasics.triangle_mesh(pl[1][]) + + f, ax, pl = poly(Polygon[]); + pl[1] = [Polygon(Point2f[(1,0), (1,1), (0,1)])]; + @test pl.plots[1][1][] == GeometryBasics.triangle_mesh.(pl[1][]) + + # PointBased inputs + f, ax, pl = poly(Point2f[]) points = decompose(Point2f, Circle(Point2f(0),1)) - pl[1] = [points] + pl[1] = points @test pl.plots[1][1][] == Makie.poly_convert(points) + + f, ax, pl = poly(Vector{Point2f}[]) + pl[1] = [points] + @test pl.plots[1][1][][1] == Makie.poly_convert(points) +end + +@testset "GridBased and ImageLike conversions" begin + # type tree + @test GridBased <: ConversionTrait + @test CellGrid <: GridBased + @test VertexGrid <: GridBased + @test ImageLike <: ConversionTrait + + # Plot to trait + @test conversion_trait(Image) === ImageLike() + @test conversion_trait(Heatmap) === CellGrid() + @test conversion_trait(Surface) === VertexGrid() + @test conversion_trait(Contour) === VertexGrid() + @test conversion_trait(Contourf) === VertexGrid() + + m1 = [x for x in 1:10, y in 1:6] + m2 = [y for x in 1:10, y in 1:6] + m3 = rand(10, 6) + + r1 = 1:10 + r2 = 1:6 + + v1 = collect(1:10) + v2 = collect(1:6) + + i1 = 1..10 + i2 = 1..6 + + o3 = Float32.(m3) + + # Conversions + @testset "ImageLike conversion" begin + @test convert_arguments(Image, m3) == (0f0..10f0, 0f0..6f0, o3) + @test convert_arguments(Image, v1, r2, m3) == (1f0..10f0, 1f0..6f0, o3) + @test convert_arguments(Image, i1, v2, m3) == (1f0..10f0, 1f0..6f0, o3) + @test_throws ErrorException convert_arguments(Image, m1, m2, m3) + @test_throws ErrorException convert_arguments(Heatmap, m1, m2) + end + + @testset "VertexGrid conversion" begin + vo1 = Float32.(v1) + vo2 = Float32.(v2) + mo1 = Float32.(m1) + mo2 = Float32.(m2) + @test convert_arguments(Surface, m3) == (vo1, vo2, o3) + @test convert_arguments(Contour, i1, v2, m3) == (vo1, vo2, o3) + @test convert_arguments(Contourf, v1, r2, m3) == (vo1, vo2, o3) + @test convert_arguments(Surface, m1, m2, m3) == (mo1, mo2, o3) + @test convert_arguments(Surface, m1, m2) == (mo1, mo2, zeros(Float32, size(o3))) + end + + @testset "CellGrid conversion" begin + o1 = Float32.(0.5:1:10.5) + o2 = Float32.(0.5:1:6.5) + @test convert_arguments(Heatmap, m3) == (o1, o2, o3) + @test convert_arguments(Heatmap, r1, i2, m3) == (o1, o2, o3) + @test convert_arguments(Heatmap, v1, r2, m3) == (o1, o2, o3) + @test convert_arguments(Heatmap, 0:10, v2, m3) == (collect(0f0:10f0), o2, o3) + @test_throws ErrorException convert_arguments(Heatmap, m1, m2, m3) + @test_throws ErrorException convert_arguments(Heatmap, m1, m2) + + # https://github.com/MakieOrg/Makie.jl/issues/3515 + @test convert_arguments(Heatmap, 1:8, 1:8, Array{Union{Float64,Missing}}(zeros(8, 8))) == (0.5:8.5, 0.5:8.5, zeros(8, 8)) + end +end + +@testset "Triplot" begin + xs = rand(Float32, 10) + ys = rand(Float32, 10) + ps = Point2f.(xs, ys) + + @test convert_arguments(Triplot, xs, ys)[1] == ps + @test convert_arguments(Triplot, ps)[1] == ps + + f, a, p = triplot(xs, ys) + tri = p.plots[1][1][] + @test tri.points ≈ ps +end + +@testset "Voronoiplot" begin + xs = rand(Float32, 10) + ys = rand(Float32, 10) + ps = Point2f.(xs, ys) + + @test convert_arguments(Voronoiplot, xs, ys)[1] == ps + @test convert_arguments(Voronoiplot, ps)[1] == ps + + f, a, p = voronoiplot(xs, ys) + tess = p.plots[1][1][] + @test Point2f[tess.generators[i] for i in 1:10] ≈ ps + + # Heatmap style signatures + xs = rand(Float32, 10) + ys = rand(Float32, 10) + zs = rand(Float32, 10, 10) + + @test convert_arguments(Voronoiplot, zs)[1] == Point3f.(1:10, (1:10)', zs)[:] + @test convert_arguments(Voronoiplot, xs, ys, zs)[1] == Point3f.(xs, ys', zs)[:] + + # color sorting + zs = [exp(-(x-y)^2) for x in LinRange(-1, 1, 10), y in LinRange(-1, 1, 10)] + fig, ax, sc = voronoiplot(1:10, 1:10, zs, markersize = 10, strokewidth = 3) + ps = [Point2f(x, y) for x in 1:10 for y in 1:10] + vorn = Makie.DelTri.voronoi(Makie.DelTri.triangulate(ps)) + sc2 = voronoiplot!(vorn, color = zs, markersize = 10, strokewidth = 3) + + for plot in (sc.plots[1], sc2) + polycols = plot.plots[1].color[] + polys = plot.plots[1][1][] + cs = zeros(10, 10) + for (p, c) in zip(polys, polycols) + # calculate center of poly, round to indices + i, j = clamp.(round.(Int, sum(first.(p.exterior)) / length(p.exterior)), 1, 10) + cs[i, j] = c + end + + @test isapprox(cs, zs, rtol = 1e-6) + end +end + +@testset "align conversions" begin + for (val, halign) in zip((0f0, 0.5f0, 1f0), (:left, :center, :right)) + @test Makie.halign2num(halign) == val + end + @test_throws ErrorException Makie.halign2num(:bottom) + @test_throws ErrorException Makie.halign2num("center") + @test Makie.halign2num(0.73) == 0.73f0 + + for (val, valign) in zip((0f0, 0.5f0, 1f0), (:bottom, :center, :top)) + @test Makie.valign2num(valign) == val + end + @test_throws ErrorException Makie.valign2num(:right) + @test_throws ErrorException Makie.valign2num("center") + @test Makie.valign2num(0.23) == 0.23f0 + + @test Makie.to_align((:center, :bottom)) == Vec2f(0.5, 0.0) + @test Makie.to_align((:right, 0.3)) == Vec2f(1.0, 0.3) + + for angle in 4pi .* rand(10) + s, c = sincos(angle) + @test Makie.angle2align(angle) ≈ Vec2f(0.5c, 0.5s) ./ max(abs(s), abs(c)) .+ Vec2f(0.5) + end + # sanity checks + @test isapprox(Makie.angle2align(pi/4), Vec2f(1, 1), atol = 1e-12) + @test isapprox(Makie.angle2align(5pi/4), Vec2f(0, 0), atol = 1e-12) end diff --git a/test/deprecated.jl b/test/deprecated.jl new file mode 100644 index 00000000000..8c0ef0db74e --- /dev/null +++ b/test/deprecated.jl @@ -0,0 +1,55 @@ +# @test_deprecated seems broken on 1.9 + 1.10 +macro depwarn_message(expr) + quote + logger = Test.TestLogger() + Base.with_logger(logger) do + $(esc(expr)) + end + if length(logger.logs) == 1 + return logger.logs[1].message + else + return nothing + end + end +end + +@testset "deprecations" begin + @testset "Scene" begin + # test that deprecated `resolution keyword still works but throws warning` + logger = Test.TestLogger() + Base.with_logger(logger) do + scene = Scene(; resolution=(999, 999), size=(123, 123)) + @test scene.viewport[] == Rect2i((0, 0), (999, 999)) + end + @test occursin("The `resolution` keyword for `Scene`s and `Figure`s has been deprecated", + logger.logs[1].message) + scene = Scene(; size=(600, 450)) + msg = @depwarn_message scene.px_area + @test occursin(".px_area` got renamed to `.viewport`, and means the area the scene maps to in device independent units", + msg) + # @test_deprecated seems to be broken on 1.10?! + msg = @depwarn_message pixelarea(scene) + # only works with depwarn on + @test occursin("`pixelarea` is deprecated, use `viewport` instead.", msg) + end + @testset "Plot -> Combined" begin + logger = Test.TestLogger() + msg = @depwarn_message Combined + @test occursin("Combined is deprecated", msg) + @test Combined == Plot + end + @testset "Surface Traits" begin + @test DiscreteSurface == CellGrid + @test ContinuousSurface == VertexGrid + msg = @depwarn_message DiscreteSurface() + @test occursin("DiscreteSurface is deprecated", msg) + msg = @depwarn_message ContinuousSurface() + @test occursin("ContinuousSurface is deprecated", msg) + end + @testset "AbstractVector ImageLike" begin + msg = @depwarn_message image(1:10, 1..10, zeros(10, 10)) + @test occursin("Encountered an `AbstractVector` with value 1:10 on side x", msg) + msg = @depwarn_message image(1..10, 1:10, zeros(10, 10)) + @test occursin("Encountered an `AbstractVector` with value 1:10 on side y", msg) + end +end diff --git a/test/events.jl b/test/events.jl index 31b2b484eb8..41b7a94d440 100644 --- a/test/events.jl +++ b/test/events.jl @@ -135,7 +135,7 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right clipboard() = CLIP[] end @testset "copy_paste" begin - f = Figure(resolution=(640,480)) + f = Figure(size=(640,480)) tb = Textbox(f[1,1], placeholder="Copy/paste into me") e = events(f.scene) @@ -166,7 +166,7 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right # Refresh figure to test right control + v combination empty!(f) - f = Figure(resolution=(640,480)) + f = Figure(size=(640,480)) tb = Textbox(f[1,1], placeholder="Copy/paste into me") e = events(f.scene) @@ -196,11 +196,11 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right # This testset is based on the results the current camera system has. If # cam3d! is updated this is likely to break. @testset "cam3d!" begin - scene = Scene(resolution=(800, 600)); + scene = Scene(size=(800, 600)); e = events(scene) cam3d!(scene, fixed_axis=true, cad=false, zoom_shift_lookat=false) cc = cameracontrols(scene) - + # Verify initial camera state @test cc.lookat[] == Vec3f(0) @test cc.eyeposition[] == Vec3f(3) @@ -218,20 +218,20 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right # 2) Outside scene, in drag e.mouseposition[] = (1000, 450) @test cc.lookat[] ≈ Vec3f(0) - @test cc.eyeposition[] ≈ Vec3f(-2.8912058, -3.8524969, -1.9491522) + @test cc.eyeposition[] ≈ Vec3f(-2.8912058, -3.8524969, -1.9491514) @test cc.upvector[] ≈ Vec3f(-0.5050875, -0.6730229, 0.5403024) # 3) not in drag e.mousebutton[] = MouseButtonEvent(Mouse.left, Mouse.release) e.mouseposition[] = (400, 250) @test cc.lookat[] ≈ Vec3f(0) - @test cc.eyeposition[] ≈ Vec3f(-2.8912058, -3.8524969, -1.9491522) + @test cc.eyeposition[] ≈ Vec3f(-2.8912058, -3.8524969, -1.9491514) @test cc.upvector[] ≈ Vec3f(-0.5050875, -0.6730229, 0.5403024) # Reset state so this is indepentent from the last checks - scene = Scene(resolution=(800, 600)); + scene = Scene(size=(800, 600)); e = events(scene) cam3d!(scene, fixed_axis=true, cad=false, zoom_shift_lookat=false) cc = cameracontrols(scene) @@ -243,29 +243,30 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right # translation # 1) In scene, in drag + e.mouseposition[] = (400, 250) e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.press) e.mouseposition[] = (600, 250) - @test cc.lookat[] ≈ Vec3f(5.4697413, -3.3484206, -2.1213205) - @test cc.eyeposition[] ≈ Vec3f(8.469742, -0.34842062, 0.8786795) + @test cc.lookat[] ≈ Vec3f(1.0146117, -1.0146117, 0.0) + @test cc.eyeposition[] ≈ Vec3f(4.0146117, 1.9853883, 3.0) @test cc.upvector[] ≈ Vec3f(0.0, 0.0, 1.0) # 2) Outside scene, in drag e.mouseposition[] = (1000, 450) - @test cc.lookat[] ≈ Vec3f(9.257657, -5.4392805, -3.818377) - @test cc.eyeposition[] ≈ Vec3f(12.257658, -2.4392805, -0.81837714) + @test cc.lookat[] ≈ Vec3f(3.6296215, -2.4580488, -1.1715729) + @test cc.eyeposition[] ≈ Vec3f(6.6296215, 0.5419513, 1.8284271) @test cc.upvector[] ≈ Vec3f(0.0, 0.0, 1.0) # 3) not in drag e.mousebutton[] = MouseButtonEvent(Mouse.right, Mouse.release) e.mouseposition[] = (400, 250) - @test cc.lookat[] ≈ Vec3f(9.257657, -5.4392805, -3.818377) - @test cc.eyeposition[] ≈ Vec3f(12.257658, -2.4392805, -0.81837714) + @test cc.lookat[] ≈ Vec3f(3.6296215, -2.4580488, -1.1715729) + @test cc.eyeposition[] ≈ Vec3f(6.6296215, 0.5419513, 1.8284271) @test cc.upvector[] ≈ Vec3f(0.0, 0.0, 1.0) # Reset state - scene = Scene(resolution=(800, 600)); + scene = Scene(size=(800, 600)); e = events(scene) cam3d!(scene, fixed_axis=true, cad=false, zoom_shift_lookat=false) cc = cameracontrols(scene) @@ -274,26 +275,24 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right @test cc.lookat[] == Vec3f(0) @test cc.eyeposition[] == Vec3f(3) @test cc.upvector[] == Vec3f(0, 0, 1) - @test cc.zoom_mult[] == 1f0 # Zoom + e.mouseposition[] = (400, 250) # for debugging e.scroll[] = (0.0, 4.0) @test cc.lookat[] ≈ Vec3f(0) - @test cc.eyeposition[] ≈ Vec3f(3) + @test cc.eyeposition[] ≈ 0.6830134f0 * Vec3f(3) @test cc.upvector[] ≈ Vec3f(0.0, 0.0, 1.0) - @test cc.zoom_mult[] ≈ 0.6830134f0 # should not work outside the scene e.mouseposition[] = (1000, 450) e.scroll[] = (0.0, 4.0) @test cc.lookat[] ≈ Vec3f(0) - @test cc.eyeposition[] ≈ Vec3f(3) + @test cc.eyeposition[] ≈ 0.6830134f0 * Vec3f(3) @test cc.upvector[] ≈ Vec3f(0.0, 0.0, 1.0) - @test cc.zoom_mult[] ≈ 0.6830134f0 end @testset "mouse state machine" begin - scene = Scene(resolution=(800, 600)); + scene = Scene(size=(800, 600)); e = events(scene) bbox = Observable(Rect2(200, 200, 400, 300)) msm = addmouseevents!(scene, bbox, priority=typemax(Int)) @@ -441,4 +440,47 @@ Base.:(==)(l::Or, r::Or) = l.left == r.left && l.right == r.right @test eventlog[7].px == Point2f(400, 400) empty!(eventlog) end + + # TODO: test more + @testset "Axis Interactions" begin + f = Figure(size = (400, 400)) + a = Axis(f[1, 1]) + e = events(f) + + names = (:rectanglezoom, :dragpan, :limitreset, :scrollzoom) + @test keys(a.interactions) == Set(names) + + types = (Makie.RectangleZoom, Makie.DragPan, Makie.LimitReset, Makie.ScrollZoom) + for (name, type) in zip(names, types) + @test a.interactions[name][1] == true + @test a.interactions[name][2] isa type + end + + blocked = Observable(true) + on(x -> blocked[] = false, e.scroll, priority = typemin(Int)) + + @assert !is_mouseinside(a.scene) + e.scroll[] = (0.0, 0.0) + @test !blocked[] + blocked[] = true + e.scroll[] = (0.0, 1.0) + @test !blocked[] + + blocked[] = true + e.mouseposition[] = (200, 200) + e.scroll[] = (0.0, 0.0) + @test blocked[] # TODO: should it block? + blocked[] = true + e.scroll[] = (0.0, 1.0) + @test blocked[] + + deactivate_interaction!.((a,), names) + + blocked[] = true + e.scroll[] = (0.0, 0.0) + @test !blocked[] + blocked[] = true + e.scroll[] = (0.0, 1.0) + @test !blocked[] + end end diff --git a/test/figures.jl b/test/figures.jl index 4d844d115af..d6968de4aba 100644 --- a/test/figures.jl +++ b/test/figures.jl @@ -17,6 +17,7 @@ end @test fig isa Figure @test ax isa Axis @test p isa Scatter + @test_throws ArgumentError lines!(fap, 1:10) fig2, ax2, p2 = scatter(rand(100, 3)) @test fig2 isa Figure @@ -34,6 +35,7 @@ end ap = scatter(gridpos, rand(100, 2)) @test ap isa Makie.AxisPlot @test current_axis() === ap.axis + @test_throws ArgumentError lines!(ap, 1:10) ax2, p2 = scatter(fig[1, 2], rand(100, 2)) @test ax2 isa Axis @@ -62,6 +64,7 @@ end current_axis!(ax2) @test current_axis() === ax2 @test current_figure() === fig + end @testset "Deleting from figures" begin @@ -155,10 +158,10 @@ end end @testset "Figure and axis kwargs validation" begin - @test_throws ArgumentError lines(1:10, axis = (aspect = DataAspect()), figure = (resolution = (100, 100))) - @test_throws ArgumentError lines(1:10, figure = (resolution = (100, 100))) + @test_throws ArgumentError lines(1:10, axis = (aspect = DataAspect()), figure = (size = (100, 100))) + @test_throws ArgumentError lines(1:10, figure = (size = (100, 100))) @test_throws ArgumentError lines(1:10, axis = (aspect = DataAspect())) - + # these just shouldn't error lines(1:10, axis = (aspect = DataAspect(),)) lines(1:10, axis = Attributes(aspect = DataAspect())) @@ -167,4 +170,4 @@ end f = Figure() @test_throws ArgumentError lines(f[1, 1], 1:10, axis = (aspect = DataAspect())) @test_throws ArgumentError lines(f[1, 1][2, 2], 1:10, axis = (aspect = DataAspect())) -end \ No newline at end of file +end diff --git a/test/hist.jl b/test/hist.jl new file mode 100644 index 00000000000..96cbc15b01f --- /dev/null +++ b/test/hist.jl @@ -0,0 +1,16 @@ +@testset "Histogram plotting" begin + unequal_vec = [1; rand(2:9, rand(1:9))] + allequal_vec = fill(rand(1:9), rand(1:9)) + # normal range + @test_nowarn hist(0:rand(1:9)) + # initialize with unequal observable vector + v = Observable(unequal_vec) + @test_nowarn hist(v) + # change to allequal vector + @test_nowarn v[] = allequal_vec + # initialize with allequal observable vector + v = Observable(allequal_vec) + @test_nowarn hist(v) + # change to unequal vector + @test_nowarn v[] = unequal_vec +end diff --git a/test/makielayout.jl b/test/makielayout.jl index df8614a2ce6..5c50e90632c 100644 --- a/test/makielayout.jl +++ b/test/makielayout.jl @@ -42,11 +42,11 @@ end _, hm = heatmap(fig[1, 1], xs, ys, zs) cb = Colorbar(fig[1, 2], hm) - @test hm.attributes[:colorrange][] == Vec(-.5, .5) + @test hm.calculated_colors[].colorrange[] == Vec(-0.5, 0.5) @test cb.limits[] == Vec(-.5, .5) - hm.attributes[:colorrange][] = Float32.((-1, 1)) - @test cb.limits[] == (-1, 1) + hm.colorrange = Float32.((-1, 1)) + @test cb.limits[] == Vec(-1, 1) # TODO: This doesn't work anymore because colorbar doesn't use the same observable # cb.limits[] = Float32.((-2, 2)) @@ -113,6 +113,48 @@ end @test ax.limits[] == (nothing, [5, 7]) @test ax.targetlimits[] == BBox(-5, 11, 5, 7) @test ax.finallimits[] == BBox(-5, 11, 5, 7) + @test_throws MethodError limits!(f[1,1], -1, 1, -1, 1) +end + +# issue 3240 +@testset "Axis limits 4-tuple" begin + fig = Figure() + ax = Axis(fig[1,1],limits=(0,600,0,15)) + xlims!(ax,100,400) + @test ax.limits[] == ((100,400),(0,15)) + xlims!() + @test ax.limits[] == ((nothing,nothing),(0,15)) + + ax = Axis(fig[1,1],limits=(0,600,0,15)) + ylims!(ax,1,13) + @test ax.limits[] == ((0,600),(1,13)) + ylims!() + @test ax.limits[] == ((0,600),(nothing,nothing)) + + ax = Axis(fig[1,1],limits=(0,600,0,15)) + limits!(ax,350,700,2,14) + @test ax.limits[] == ((350,700),(2,14)) +end + +@testset "Axis3 limits 6-tuple" begin + fig = Figure() + ax = Axis3(fig[1,1],limits=(0,1,0,2,0,3)) + xlims!(ax,1,2) + @test ax.limits[] == ((1,2),(0,2),(0,3)) + xlims!() + @test ax.limits[] == ((nothing,nothing),(0,2),(0,3)) + + ax = Axis3(fig[1,1],limits=(0,1,0,2,0,3)) + ylims!(ax,1,3) + @test ax.limits[] == ((0,1),(1,3),(0,3)) + ylims!() + @test ax.limits[] == ((0,1),(nothing,nothing),(0,3)) + + ax = Axis3(fig[1,1],limits=(0,1,0,2,0,3)) + zlims!(ax,1,5) + @test ax.limits[] == ((0,1),(0,2),(1,5)) + zlims!() + @test ax.limits[] == ((0,1),(0,2),(nothing,nothing)) end @testset "Colorbar plot object kwarg clash" begin @@ -168,6 +210,26 @@ end @test ticklabel_strings[1] == "0.0" @test ticklabel_strings[end] == "1.0" end + @testset "errors" begin + f, ax, pl1 = scatter(rand(10)) + pl2 = scatter!(ax, rand(10); color=rand(RGBf, 10)) + pl3 = barplot!(ax, 1:3; colorrange=(0, 1)) + @test_throws ErrorException Colorbar(f[1, 2], pl1) + @test_throws ErrorException Colorbar(f[1, 2], pl2) + @test_throws ErrorException Colorbar(f[1, 2], pl3) + end + @testset "Recipes" begin + f, ax, pl = barplot(1:3; color=1:3) + cbar = Colorbar(f[1, 2], pl) + @test cbar.limits[] == Vec(1.0, 3.0) + + let data = fill(1.0, 2,2,2) + data[1] = 3.0 + f, ax, pl = volumeslices(1:2, 1:2, 1:2, data) + cbar = Colorbar(f[1,2], pl) + @test cbar.limits[] == Vec(1.0, 3.0) + end + end end @testset "cycling" begin @@ -344,7 +406,7 @@ end (label = "Frequency", range = 0:0.5:50, format = "{:.1f}Hz", startvalue = 10), (label = "Phase", range = 0:0.01:2pi, format = x -> string(round(x/pi, digits = 2), "π")) - ) + ) end @test isempty(d) end @@ -375,4 +437,70 @@ end f, ax, h = hist(randn(100), bar_labels = :y, label = "My histogram") @test_nowarn axislegend() f -end \ No newline at end of file +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 + try + fig, ax, li = lines(1:10, 1:10) + vlines!(ax, 3) + hlines!(ax, 3) + bp = barplot!(ax, 1 .+ 5 .* rand(10)) + vspan!(ax, 3, 4) + hspan!(ax, 3, 4) + bracket!(ax, 1, 1, 2, 2) + eb = errorbars!(ax, 1:10, 1:10, [0.3 for _ in 1:10], whiskerwidth = 5) + text!(ax, Point2f(2), text = "abba") + tooltip!(ax, Point2f(8), "baab") + tricontourf!(ax, 1 .+ 4 .* rand(5), 1 .+ 4 .* rand(5), rand(5)) + qqplot!(ax, 5:10, 1:5) + ax.yscale = log10 + ax.yscale = identity + ax.yscale = log10 + ax.yscale = identity + @test true + catch e + @test false + rethrow(e) + end +end + +@testset "Block attribute conversion observable cleanup" begin + limits = Observable((-1.0, 1.0, -1.0, 1.0)) + for _ in 1:5 + fig = Figure() + for i in 1:5 + for j in 1:5 + ax = Axis(fig[i, j]; limits) + end + end + empty!(fig) + end + @test isempty(limits.listeners) +end diff --git a/test/pipeline.jl b/test/pipeline.jl index 3faf0ca29ff..6f18bc98729 100644 --- a/test/pipeline.jl +++ b/test/pipeline.jl @@ -30,7 +30,120 @@ end xmax = Observable{Any}([0.25, 0.5, 0.75, 1]) p = hlines!(ax, list, xmax = xmax, color = :blue) - @test getfield(p, :input_args)[1] === list + @test getfield(p, :args)[1] === list @test p.xmax === xmax fig end + + +@testset "Figure / Axis / Gridposition creation test" begin + @testset "proper errors for wrongly used (non) mutating plot functions" begin + f = Figure() + x = range(0, 10, length=100) + @test_throws ErrorException scatter!(f[1, 1], x, sin) + @test_throws ErrorException scatter!(f[1, 2][1, 1], x, sin) + @test_throws ErrorException scatter!(f[1, 2][1, 2], x, sin) + + @test_throws ErrorException meshscatter!(f[2, 1], x, sin; axis=(type=Axis3,)) + @test_throws ErrorException meshscatter!(f[2, 2][1, 1], x, sin; axis=(type=Axis3,)) + @test_throws ErrorException meshscatter!(f[2, 2][1, 2], x, sin; axis=(type=Axis3,)) + + @test_throws ErrorException meshscatter!(f[3, 1], rand(Point3f, 10); axis=(type=LScene,)) + @test_throws ErrorException meshscatter!(f[3, 2][1, 1], rand(Point3f, 10); axis=(type=LScene,)) + @test_throws ErrorException meshscatter!(f[3, 2][1, 2], rand(Point3f, 10); axis=(type=LScene,)) + + sub = f[4, :] + f = Figure() + @test_throws ErrorException scatter(Axis(f[1, 1]), x, sin) + @test_throws ErrorException meshscatter(Axis3(f[1, 1]), x, sin) + @test_throws ErrorException meshscatter(LScene(f[1, 1]), rand(Point3f, 10)) + + f + end + + @testset "creating plot object for different (non) mutating plotting functions into figure" begin + f = Figure() + x = range(0, 10; length=100) + ax, pl = scatter(f[1, 1], x, sin) + @test ax isa Axis + @test pl isa AbstractPlot + + ax, pl = scatter(f[1, 2][1, 1], x, sin) + @test ax isa Axis + @test pl isa AbstractPlot + + ax, pl = scatter(f[1, 2][1, 2], x, sin) + @test ax isa Axis + @test pl isa AbstractPlot + + ax, pl = meshscatter(f[2, 1], x, sin; axis=(type=Axis3,)) + @test ax isa Axis3 + @test pl isa AbstractPlot + + ax, pl = meshscatter(f[2, 2][1, 1], x, sin; axis=(type=Axis3,)) + @test ax isa Axis3 + @test pl isa AbstractPlot + ax, pl = meshscatter(f[2, 2][1, 2], x, sin; axis=(type=Axis3,)) + @test ax isa Axis3 + @test pl isa AbstractPlot + + ax, pl = meshscatter(f[3, 1], rand(Point3f, 10); axis=(type=LScene,)) + @test ax isa LScene + @test pl isa AbstractPlot + ax, pl = meshscatter(f[3, 2][1, 1], rand(Point3f, 10); axis=(type=LScene,)) + @test ax isa LScene + @test pl isa AbstractPlot + ax, pl = meshscatter(f[3, 2][1, 2], rand(Point3f, 10); axis=(type=LScene,)) + @test ax isa LScene + @test pl isa AbstractPlot + + sub = f[4, :] + + pl = scatter!(Axis(sub[1, 1]), x, sin) + @test pl isa AbstractPlot + pl = meshscatter!(Axis3(sub[1, 2]), x, sin) + @test pl isa AbstractPlot + pl = meshscatter!(LScene(sub[1, 3]), rand(Point3f, 10)) + @test pl isa AbstractPlot + + f = Figure() + @test_throws ErrorException lines!(f, [1, 2]) + end +end + +@testset "Cycled" begin + # Test for https://github.com/MakieOrg/Makie.jl/issues/3266 + f, ax, pl = lines(1:4; color=Cycled(2)) + cpalette = ax.scene.theme.palette[:color][] + @test pl.calculated_colors[] == cpalette[2] + pl2 = lines!(ax, 1:4; color=Cycled(1)) + @test pl2.calculated_colors[] == cpalette[1] +end + +function test_default(arg) + _, _, pl1 = plot(arg) + + fig = Figure() + _, pl2 = plot(fig[1, 1], arg) + + fig = Figure() + ax = Axis(fig[1, 1]) + pl3 = plot!(ax, arg) + return [pl1, pl2, pl3] +end + +@testset "plot defaults" begin + plots = test_default([10, 15, 20]) + @test all(x-> x isa Scatter, plots) + + plots = test_default(rand(4, 4)) + @test all(x -> x isa Heatmap, plots) + + poly = Polygon(decompose(Point, Circle(Point2f(0), 1.0f0))) + + plots = test_default(poly) + @test all(x -> x isa Poly, plots) + + plots = test_default(rand(4, 4, 4)) + @test all(x -> x isa Volume, plots) +end diff --git a/test/primitives.jl b/test/primitives.jl new file mode 100644 index 00000000000..ad5a74ad1e6 --- /dev/null +++ b/test/primitives.jl @@ -0,0 +1,78 @@ +@testset "ablines" begin + # Test ablines with 0 dim arrays + f, ax, pl = ablines(fill(0), fill(1)) + reset_limits!(ax) + points = pl.plots[1][1] + @test points[] == [Point2f(0), Point2f(10)] + limits!(ax, 5, 15, 6, 17) + @test points[] == [Point2f(5), Point2f(15)] +end + + +@testset "arrows" begin + # Test for: + # https://github.com/MakieOrg/Makie.jl/issues/3273 + directions = decompose(Point2f, Circle(Point2f(0), 1)) + points = decompose(Point2f, Circle(Point2f(0), 0.5)) + color = range(0, 1, length=length(directions)) + fig, ax, pl = arrows(points, directions; color=color) + cbar = Colorbar(fig[1, 2], pl) + @test cbar.limits[] == Vec2f(0, 1) + pl.colorrange = (0.5, 0.6) + @test cbar.limits[] ≈ Vec2f(0.5, 0.6) +end + + +# TODO, test all primitives and argument conversions + +# text() +# fig, ax, p = meshscatter(1:5, (1:5) .+ 5, rand(5)) +# fig, ax, p = scatter(1:5, rand(5)) +# fig, ax, p = mesh(Sphere(Point3f(0), 1.0)) +# fig, ax, p = linesegments(1:5, rand(5)) +# fig, ax, p = lines(1:5, rand(5)) +# fig, ax, p = surface(rand(4, 7)) +# fig, ax, p = volume(rand(4, 4, 4)) +# begin +# fig, ax, p = heatmap(rand(4, 4)) +# scatter!(Makie.point_iterator(p) |> collect, color=:red, markersize=10) +# display(fig) +# end + +# fig, ax, p = image(rand(4, 5)) +# scatter!(Makie.point_iterator(p) |> collect, color=:red, markersize=10) +# display(fig) + +# begin +# fig, ax, p = scatter(1:5, rand(5)) +# linesegments!(Makie.data_limits(ax.scene), color=:red) +# display(fig) +# end + +@testset "barplot errors for three args" begin + @test_throws ErrorException barplot(1:10, 1:10, 1:10) +end + +# https://github.com/MakieOrg/Makie.jl/issues/3551 +@testset "scalar color for scatterlines" begin + colorrange = (1, 5) + colormap = :Blues + f, ax, sl = scatterlines(1:10,1:10,color=3,colormap=colormap,colorrange=colorrange) + l = sl.plots[1]::Lines + sc = sl.plots[2]::Scatter + @test l.color[] == 3 + @test l.colorrange[] == colorrange + @test l.colormap[] == colormap + @test sc.color[] == 3 + @test sc.colorrange[] == colorrange + @test sc.colormap[] == colormap + sl.markercolor = 4 + sl.markercolormap = :jet + sl.markercolorrange = (2, 7) + @test l.color[] == 3 + @test l.colorrange[] == colorrange + @test l.colormap[] == colormap + @test sc.color[] == 4 + @test sc.colorrange[] == (2, 7) + @test sc.colormap[] == :jet +end diff --git a/test/ray_casting.jl b/test/ray_casting.jl index ec88508ba18..73b5beaa20a 100644 --- a/test/ray_casting.jl +++ b/test/ray_casting.jl @@ -1,7 +1,7 @@ @testset "Ray Casting" begin @testset "View Rays" begin scene = Scene() - xy = 0.5 * widths(pixelarea(scene)[]) + xy = 0.5 * widths(viewport(scene)[]) orthographic_cam3d!(x) = cam3d!(x, perspectiveprojection = Makie.Orthographic) @@ -20,13 +20,13 @@ end - # transform() is used to apply a translation-rotation-scale matrix to rays + # transform() is used to apply a translation-rotation-scale matrix to rays # instead of point like data # Generate random point + transform rot = Makie.rotation_between(rand(Vec3f), rand(Vec3f)) model = Makie.transformationmatrix(rand(Vec3f), rand(Vec3f), rot) point = Point3f(1) + rand(Point3f) - + # Generate rate that passes through transformed point transformed = Point3f(model * Point4f(point..., 1)) direction = (1 + 10*rand()) * rand(Vec3f) @@ -71,76 +71,76 @@ end - # Note that these tests depend on the exact placement of plots and may + # Note that these tests depend on the exact placement of plots and may # error when cameras are adjusted @testset "position_on_plot()" begin - + # Lines (2D) & Linesegments (3D) ps = [exp(-0.01phi) * Point2f(cos(phi), sin(phi)) for phi in range(0, 20pi, length = 501)] - scene = Scene(resolution = (400, 400)) + scene = Scene(size = (400, 400)) p = lines!(scene, ps) cam2d!(scene) ray = Makie.Ray(scene, (325.0, 313.0)) pos = Makie.position_on_plot(p, 157, ray) @test pos ≈ Point3f(0.6087957666683925, 0.5513198993583837, 0.0) - - scene = Scene(resolution = (400, 400)) + + scene = Scene(size = (400, 400)) p = linesegments!(scene, ps) cam3d!(scene) ray = Makie.Ray(scene, (238.0, 233.0)) pos = Makie.position_on_plot(p, 178, ray) @test pos ≈ Point3f(-0.7850463447725504, -0.15125213957100314, 0.0) - - + + # Heatmap (2D) & Image (3D) - scene = Scene(resolution = (400, 400)) + scene = Scene(size = (400, 400)) p = heatmap!(scene, 0..1, -1..1, rand(10, 10)) cam2d!(scene) ray = Makie.Ray(scene, (228.0, 91.0)) pos = Makie.position_on_plot(p, 0, ray) @test pos ≈ Point3f(0.13999999, -0.54499996, 0.0) - - scene = Scene(resolution = (400, 400)) + + scene = Scene(size = (400, 400)) p = image!(scene, -1..1, -1..1, rand(10, 10)) cam3d!(scene) ray = Makie.Ray(scene, (309.0, 197.0)) pos = Makie.position_on_plot(p, 3, ray) @test pos ≈ Point3f(-0.7830243, 0.8614166, 0.0) - - + + # Mesh (3D) - scene = Scene(resolution = (400, 400)) + scene = Scene(size = (400, 400)) p = mesh!(scene, Rect3f(Point3f(0), Vec3f(1))) cam3d!(scene) ray = Makie.Ray(scene, (201.0, 283.0)) pos = Makie.position_on_plot(p, 15, ray) @test pos ≈ Point3f(0.029754717, 0.043159597, 1.0) - + # Surface (3D) - scene = Scene(resolution = (400, 400)) + scene = Scene(size = (400, 400)) p = surface!(scene, -2..2, -2..2, [sin(x) * cos(y) for x in -10:10, y in -10:10]) cam3d!(scene) ray = Makie.Ray(scene, (52.0, 238.0)) pos = Makie.position_on_plot(p, 57, ray) @test pos ≈ Point3f(0.80910987, -1.6090667, 0.137722) - + # Volume (3D) - scene = Scene(resolution = (400, 400)) + scene = Scene(size = (400, 400)) p = volume!(scene, rand(10, 10, 10)) cam3d!(scene) center!(scene) ray = Makie.Ray(scene, (16.0, 306.0)) pos = Makie.position_on_plot(p, 0, ray) - @test pos ≈ Point3f(10.0, 0.18444633, 9.989262) + @test pos ≈ Point3f(10.0, 0.08616829, 9.989262) end # For recreating the above: - #= + #= # Scene setup from tests: - scene = Scene(resolution = (400, 400)) + scene = Scene(size = (400, 400)) p = surface!(scene, -2..2, -2..2, [sin(x) * cos(y) for x in -10:10, y in -10:10]) cam3d!(scene) - + pos = Observable(Point3f(0.5)) on(events(scene).mousebutton, priority = 100) do event if event.button == Mouse.left && event.action == Mouse.press @@ -160,4 +160,4 @@ scene =# -end \ No newline at end of file +end diff --git a/test/runtests.jl b/test/runtests.jl index 1e2e257d552..929f8d4d473 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -18,6 +18,9 @@ using Makie: volume @test all(hi .>= (8,8,10)) end + include("deprecated.jl") + include("specapi.jl") + include("primitives.jl") include("pipeline.jl") include("record.jl") include("scenes.jl") @@ -28,9 +31,12 @@ using Makie: volume include("makielayout.jl") include("figures.jl") include("transformations.jl") - include("stack.jl") include("events.jl") include("text.jl") include("boundingboxes.jl") include("ray_casting.jl") + include("PolarAxis.jl") + include("barplot.jl") + include("bezier.jl") + include("hist.jl") end diff --git a/test/scenes.jl b/test/scenes.jl index e16cc6049f3..a2c26e96b92 100644 --- a/test/scenes.jl +++ b/test/scenes.jl @@ -4,4 +4,62 @@ @testset "getproperty(scene, :$field)" for field in fieldnames(Scene) @test getproperty(scene, field) !== missing # well, just don't error end + @test theme(nothing, :nonexistant, default=1) == 1 + @test theme(scene, :nonexistant, default=1) == 1 +end + +@testset "Lighting" begin + @testset "Shading default" begin + plot = (attributes = Attributes(), ) # simplified "plot" + + # Based on number of lights + lights = Makie.AbstractLight[] + Makie.default_shading!(plot, lights) + @test !haskey(plot.attributes, :shading) + + plot.attributes[:shading] = Observable(Makie.automatic) + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === NoShading + + plot.attributes[:shading] = Observable(Makie.automatic) + push!(lights, AmbientLight(RGBf(0.1, 0.1, 0.1))) + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === FastShading + + plot.attributes[:shading] = Observable(Makie.automatic) + push!(lights, DirectionalLight(RGBf(0.1, 0.1, 0.1), Vec3f(1))) + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === FastShading + + plot.attributes[:shading] = Observable(Makie.automatic) + push!(lights, PointLight(RGBf(0.1, 0.1, 0.1), Point3f(0))) + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === MultiLightShading + + # Based on light types + plot.attributes[:shading] = Observable(Makie.automatic) + lights = [SpotLight(RGBf(0.1, 0.1, 0.1), Point3f(0), Vec3f(1), Vec2f(0.2, 0.3))] + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === MultiLightShading + + plot.attributes[:shading] = Observable(Makie.automatic) + lights = [EnvironmentLight(1.0, rand(2,2))] + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === NoShading # only affects RPRMakie so skipped here + + plot.attributes[:shading] = Observable(Makie.automatic) + lights = [PointLight(RGBf(0.1, 0.1, 0.1), Point3f(0))] + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === MultiLightShading + + plot.attributes[:shading] = Observable(Makie.automatic) + lights = [PointLight(RGBf(0.1, 0.1, 0.1), Point3f(0), Vec2f(0.1, 0.2))] + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === MultiLightShading + + # keep existing shading type + lights = Makie.AbstractLight[] + Makie.default_shading!(plot, lights) + @test to_value(plot.attributes[:shading]) === MultiLightShading + end end diff --git a/test/specapi.jl b/test/specapi.jl new file mode 100644 index 00000000000..3cff1e024ba --- /dev/null +++ b/test/specapi.jl @@ -0,0 +1,73 @@ +import Makie.SpecApi as S + +@testset "diffing" begin + @testset "update_plot!" begin + obs = Observable[] + oldspec = S.Scatter(1:4; cycle=[]) + newspec = S.Scatter(1:4; cycle=[]) + p = Makie.to_plot_object(newspec) + s = Scene() + plot!(s, p) + Makie.update_plot!(obs, p, oldspec, newspec) + @test isempty(obs) + + newspec = S.Scatter(1:4; color=:red) + Makie.update_plot!(obs, p, oldspec, newspec) + oldspec = newspec + @test length(obs) == 1 + @test obs[1] === p.color + + newspec = S.Scatter(1:4; color=:green, cycle=[]) + empty!(obs) + Makie.update_plot!(obs, p, oldspec, newspec) + oldspec = newspec + @test length(obs) == 1 + @test obs[1] === p.color + @test obs[1].val == to_color(:green) + + newspec = S.Scatter(1:5; color=:green, cycle=[]) + empty!(obs) + Makie.update_plot!(obs, p, oldspec, newspec) + oldspec = newspec + @test length(obs) == 1 + @test obs[1] === p.args[1] + + oldspec = S.Scatter(1:5; color=:green, marker=:rect, cycle=[]) + newspec = S.Scatter(1:4; color=:red, marker=:circle, cycle=[]) + empty!(obs) + p = Makie.to_plot_object(oldspec) + s = Scene() + plot!(s, p) + Makie.update_plot!(obs, p, oldspec, newspec) + @test length(obs) == 3 + @test obs[1] === p.args[1] + @test obs[2] === p.color + @test obs[3] === p.marker + end + + @testset "diff_plotlist!" begin + scene = Scene(); + plotspecs = [S.Scatter(1:4; color=:red), S.Scatter(1:4; color=:red)] + reusable_plots = IdDict{PlotSpec,Plot}() + obs_to_notify = Observable[] + new_plots = Makie.diff_plotlist!(scene, plotspecs, obs_to_notify, reusable_plots) + @test length(new_plots) == 2 + @test Set(scene.plots) == Set(values(new_plots)) + @test isempty(obs_to_notify) + + new_plots2 = Makie.diff_plotlist!(scene, plotspecs, obs_to_notify, new_plots) + + @test isempty(new_plots) # they got all used up + @test Set(scene.plots) == Set(values(new_plots2)) + @test isempty(obs_to_notify) + + plotspecs = [S.Scatter(1:4; color=:yellow), S.Scatter(1:4; color=:green)] + new_plots3 = Makie.diff_plotlist!(scene, plotspecs, obs_to_notify, new_plots2) + + @test isempty(new_plots) # they got all used up + @test Set(scene.plots) == Set(values(new_plots3)) + # TODO, and some point we should try to find the matching plot and just + # switch them, so we don't need an update! + @test Set(obs_to_notify) == Set([scene.plots[1].color, scene.plots[2].color]) + end +end diff --git a/test/stack.jl b/test/stack.jl deleted file mode 100644 index e03b5779a8b..00000000000 --- a/test/stack.jl +++ /dev/null @@ -1,38 +0,0 @@ -using Makie: stack_grouped_from_to - -@testset "grouped bar: stack" begin - x1 = [1, 1, 1, 1] - grp_dodge1 = [2, 2, 1, 1] - grp_stack1 = [1, 2, 1, 2] - y1 = [2, 3, -3, -2] - - x2 = [2, 2, 2, 2] - grp_dodge2 = [3, 4, 3, 4] - grp_stack2 = [3, 4, 3, 4] - y2 = [2, 3, -3, -2] - - from, to = stack_grouped_from_to(grp_stack1, y1, (; x1 = x1, grp_dodge1 = grp_dodge1)) - from1 = [0.0, 2.0, 0.0, -3.0] - to1 = [2.0, 5.0, -3.0, -5.0] - @test from == from1 - @test to == to1 - - from, to = stack_grouped_from_to(grp_stack2, y2, (; x2 = x2, grp_dodge2 = grp_dodge2)) - from2 = [0.0, 0.0, 0.0, 0.0] - to2 = [2.0, 3.0, -3.0, -2.0] - @test from == from2 - @test to == to2 - - perm = [1, 4, 2, 7, 5, 3, 8, 6] - x = [x1; x2][perm] - y = [y1; y2][perm] - grp_dodge = [grp_dodge1; grp_dodge2][perm] - grp_stack = [grp_stack1; grp_stack2][perm] - - from_test = [from1; from2][perm] - to_test = [to1; to2][perm] - - from, to = stack_grouped_from_to(grp_stack, y, (; x = x, grp_dodge = grp_dodge)) - @test from == from_test - @test to == to_test -end diff --git a/test/test_primitives.jl b/test/test_primitives.jl deleted file mode 100644 index 851d56866a4..00000000000 --- a/test/test_primitives.jl +++ /dev/null @@ -1,25 +0,0 @@ -using Makie - -text() -fig, ax, p = meshscatter(1:5, (1:5) .+ 5, rand(5)) -fig, ax, p = scatter(1:5, rand(5)) -fig, ax, p = mesh(Sphere(Point3f(0), 1.0)) -fig, ax, p = linesegments(1:5, rand(5)) -fig, ax, p = lines(1:5, rand(5)) -fig, ax, p = surface(rand(4, 7)) -fig, ax, p = volume(rand(4, 4, 4)) -begin - fig, ax, p = heatmap(rand(4, 4)) - scatter!(Makie.point_iterator(p) |> collect, color=:red, markersize=10) - display(fig) -end - -fig, ax, p = image(rand(4, 5)) -scatter!(Makie.point_iterator(p) |> collect, color=:red, markersize=10) -display(fig) - -begin - fig, ax, p = scatter(1:5, rand(5)) - linesegments!(Makie.data_limits(ax.scene), color=:red) - display(fig) -end diff --git a/test/text.jl b/test/text.jl index c0b1f427eae..4881afa3a12 100644 --- a/test/text.jl +++ b/test/text.jl @@ -1,3 +1,20 @@ +@testset "texture atlas" begin + @testset "defaults" for arg in [(1024, 32), (2048, 64)] + # Makes sure hashing and downloading default texture atlas works: + atlas = Makie.get_texture_atlas(arg...) + data = copy(atlas.data) + len = length(atlas.mapping) + # Make sure that all default glyphs are already in there + Makie.render_default_glyphs!(atlas) + # So no rendering & no change of data should happen in default glyphs are present! + @test data == atlas.data + @test length(atlas.mapping) == len + + @test haskey(Makie.TEXTURE_ATLASES, arg) # gets into global texture atlas cache + @test Makie.TEXTURE_ATLASES[arg] === atlas + end +end + @testset "Glyph Collections" begin using Makie.FreeTypeAbstraction diff --git a/test/transformations.jl b/test/transformations.jl index fd568f82534..ff3366a9737 100644 --- a/test/transformations.jl +++ b/test/transformations.jl @@ -87,6 +87,52 @@ end @test apply_transform(t1, r2) == Rect2f(apply_transform(t1, pa), apply_transform(t1, pb) .- apply_transform(t1, pa) ) end +@testset "Polar Transform" begin + tf = Makie.Polar() + @test tf.theta_as_x == true + @test tf.clip_r == true + @test tf.theta_0 == 0.0 + @test tf.direction == 1 + @test tf.r0 == 0.0 + + 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 + + tf = Makie.Polar(pi/2, 1, 0, false) + input = Point2f.(1:6, [0, pi/3, pi/2, pi, 2pi, 3pi]) + output = [r * Point2f(cos(phi+pi/2), sin(phi+pi/2)) for (r, phi) in input] + inv = Point2f.(1:6, mod.([0, pi/3, pi/2, pi, 2pi, 3pi], (0..2pi,))) + @test apply_transform(tf, input) ≈ output + @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv + + tf = Makie.Polar(pi/2, -1, 0, false) + 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, 0.5, false) + 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(0, 1, 0, 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 + + tf = Makie.Polar(0, 1, 0, true, false) + input = Point2f.([0, pi/3, pi/2, pi, 2pi, 3pi], -6:-1) + output = [r * Point2f(cos(phi), sin(phi)) for (phi, r) in input] + inv = Point2f.(mod.([0, pi/3, pi/2, pi, 2pi, 3pi] .+ pi, (0..2pi,)), 6:-1:1) + @test apply_transform(tf, input) ≈ output + @test apply_transform(Makie.inverse_transform(tf), output) ≈ inv +end + @testset "Coordinate Systems" begin funcs = [Makie.is_data_space, Makie.is_pixel_space, Makie.is_relative_space, Makie.is_clip_space] spaces = [:data, :pixel, :relative, :clip] @@ -125,8 +171,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 ) @@ -141,5 +187,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 diff --git a/test/zoom_pan.jl b/test/zoom_pan.jl index 8a5a3e5daa7..5fd708e80df 100644 --- a/test/zoom_pan.jl +++ b/test/zoom_pan.jl @@ -4,7 +4,7 @@ using Observables function cleanaxes() fig = Figure() ax = Axis(fig[1, 1]) - axbox = pixelarea(ax.scene)[] + axbox = viewport(ax.scene)[] lim = ax.finallimits[] e = events(ax.scene) return ax, axbox, lim, e @@ -79,7 +79,7 @@ end fig = Figure() ax = Axis(fig[1, 1]) plot!(ax, [10, 15, 20]) - axbox = pixelarea(ax.scene)[] + axbox = viewport(ax.scene)[] lim = ax.finallimits[] e = events(ax.scene) diff --git a/tooling/bump_versions.jl b/tooling/bump_versions.jl new file mode 100644 index 00000000000..d1d458649fe --- /dev/null +++ b/tooling/bump_versions.jl @@ -0,0 +1,112 @@ +using REPL.TerminalMenus +using TOML +using Pkg + +dictmap(f, d) = Dict(key => f(key, value) for (key, value) in d) + +bump_patch(v::VersionNumber) = VersionNumber(v.major, v.minor, v.patch+1) +bump_minor(v::VersionNumber) = VersionNumber(v.major, v.minor+1, 0) +bump_major(v::VersionNumber) = VersionNumber(v.major+1, 0, 0) + +function bump_versions() + + names = ["MakieCore", "Makie", "CairoMakie", "GLMakie", "WGLMakie", "RPRMakie"] + paths = map(names) do name + name == "Makie" ? "." : name + end + + packages = Dict(names .=> paths) + + root = joinpath(@__DIR__, "..") + + tomlpaths = dictmap(packages) do _, dir + joinpath(root, dir, "Project.toml") + end + + tomls = dictmap(tomlpaths) do _, tomlfile + TOML.parsefile(tomlfile) + end + + versions = dictmap(tomls) do _, toml + VersionNumber(toml["version"]) + end + current_version = versions["Makie"] + current_tag = "v$current_version" + + src_changes = dictmap(packages) do _, dir + srcdir = joinpath(root, dir, "src") + read(`git diff $current_tag HEAD --stat -- $srcdir`, String) + end + + has_changed_src = dictmap((key, changes) -> !isempty(changes), src_changes) + + selected = findall(map(names) do name + if has_changed_src["MakieCore"] + true + elseif has_changed_src["Makie"] + name != "MakieCore" + else + has_changed_src[name] + end + end) + + println("Which packages' versions do you want to bump? All packages with nonempty git diffs in their `src` directory are preselected, or those who depend on others that have changes.") + bumps_requested = request(MultiSelectMenu(names; selected)) + + if 1 in bumps_requested + if !isempty(setdiff(2:6, bumps_requested)) + @warn "Because MakieCore is bumped, all other packages will be bumped as well." + union!(bumps_requested, 2:6) + end + elseif 2 in bumps_requested + if !isempty(setdiff(3:6, bumps_requested)) + @warn "Because Makie is bumped, all backend packages will be bumped as well." + union!(bumps_requested, 3:6) + end + end + + println("How do you want to bump the versions:") + version_selection = request(RadioMenu(["All patch", "All minor", "All major", "Custom"])) + + version_types = map(sort(collect(bumps_requested))) do i + if version_selection == 4 + name = names[i] + version = versions[name] + v_patch = bump_patch(version) + v_minor = bump_minor(version) + v_major = bump_major(version) + println("How do you want to bump $(names[i]) (currently $version)?") + request(RadioMenu(["Patch ($v_patch)", "Minor ($v_minor)", "Major ($v_major)"])) + else + version_selection + end + end + + new_versions = Dict(map(zip(bumps_requested, version_types)) do (i, vtype) + name = names[i] + version = versions[name] + new_version = (bump_patch, bump_minor, bump_major)[vtype](version) + name => new_version + end) + + for (name, new_version) in new_versions + new_toml = deepcopy(tomls[name]) + new_toml["version"] = new_version + + compat = new_toml["compat"] + if haskey(new_versions, "Makie") && haskey(compat, "Makie") + compat["Makie"] = "=$(new_versions["Makie"])" + end + if haskey(new_versions, "MakieCore") && haskey(compat, "MakieCore") + compat["MakieCore"] = "=$(new_versions["MakieCore"])" + end + + println("Writing $(tomlpaths[name])") + open(tomlpaths[name], "w") do io + Pkg.Types.write_project(io, new_toml) + end + end + println("Done") +end + +bump_versions() \ No newline at end of file