diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1336a0d2..aa05e6bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: arch: ${{ matrix.arch }} - uses: julia-actions/cache@v1 - uses: julia-actions/julia-buildpkg@v1 + - run: julia --project=. -e 'using Pkg; pkg"add Makie#sd/geomakie CairoMakie#sd/geomakie GLMakie#sd/geomakie"' # TODO remove this later! - uses: julia-actions/julia-runtest@v1 - uses: actions/upload-artifact@v3 if: always() diff --git a/examples/axis_config.jl b/examples/axis_config.jl index 550c1b61..f5df7d4e 100644 --- a/examples/axis_config.jl +++ b/examples/axis_config.jl @@ -5,7 +5,8 @@ fig = Figure(resolution = (1000,1000)) axs = [GeoAxis(fig[i, j]) for i in 1:2, j in 1:2] # axis 1 - I want an orthographic projection. -axs[1, 1].scene.transformation.transform_func[] = Proj.Transformation("+proj=latlong","+proj=ortho") +# axis 1 does not work - TODO! +axs[1, 1].source_projection[] = "+proj=ortho" xlims!(axs[1, 1], -90, 90) # axis 2 - wacky spines diff --git a/examples/field_and_countries.jl b/examples/field_and_countries.jl index 31db2e66..e6fe9275 100644 --- a/examples/field_and_countries.jl +++ b/examples/field_and_countries.jl @@ -8,7 +8,7 @@ using GeometryBasics using GeoInterface # https://datahub.io/core/geo-countries#curl # download data from here -worldCountries = GeoJSON.read(read(Downloads.download("https://raw.githubusercontent.com/johan/world.geo.json/master/countries.geo.json"), String)) +worldCountries = GeoJSON.read(read(Downloads.download("https://raw.githack.com/johan/world.geo.json/master/countries.geo.json"), String)) n = length(worldCountries) lons = -180:180 lats = -90:90 @@ -18,9 +18,10 @@ fig = Figure(resolution = (1200,800), fontsize = 22) ax = GeoAxis( fig[1,1]; - dest = "+proj=wintri", + target_projection = "+proj=vandg", title = "World Countries", tellheight = true, + limits = ((-180, 180), (-90, 90)) ) hm1 = surface!(ax, lons, lats, field; shading = false) @@ -36,4 +37,6 @@ hm2 = poly!( cb = Colorbar(fig[1,2]; colorrange = (1, n), colormap = Reverse(:plasma), label = "variable, color code", height = Relative(0.65)) + + fig diff --git a/examples/orthographic.jl b/examples/orthographic.jl index 141c17af..f8f933ec 100644 --- a/examples/orthographic.jl +++ b/examples/orthographic.jl @@ -10,12 +10,14 @@ field = [exp(cosd(l)) + 3(y/90) for l in lons, y in lats] fig = Figure() ga = GeoAxis( fig[1, 1], - dest="+proj=ortho", - lonlims = automatic, - coastlines = true, - title = "Orthographic projection with proper limits" + target_projection="+proj=ortho", + title = "Orthographic projection with proper limits", + limits = ((-90, 90), (-90, 90)) # have to specify proper limits here - TODO bring back autolimit finding! ) -# hidedecorations!(ga) + +# TODO: bring back coastlines, or create it as a recipe... +lp = lines!(ga, GeoMakie.coastlines()) +translate!(lp, 0, 0, 10) sp = surface!(ga, lons, lats, field; shading = false, colormap = :rainbow_bgyrm_35_85_c69_n256) cb = Colorbar(fig[1, 2], sp) diff --git a/examples/projections.jl b/examples/projections.jl index 12878046..acc3c70f 100644 --- a/examples/projections.jl +++ b/examples/projections.jl @@ -5,9 +5,9 @@ lats = -90:90 field = [exp(cosd(l)) + 3(y / 90) for l in lons, y in lats] fig = Figure() -ax1 = GeoAxis(fig[1, 1], dest = "+proj=vitk1 +lat_1=45 +lat_2=55", +ax1 = GeoAxis(fig[1, 1], target_projection = "+proj=vitk1 +lat_1=45 +lat_2=55", coastlines = true, title = "vitk1") -ax2 = GeoAxis(fig[1, 2], dest = "+proj=wintri", +ax2 = GeoAxis(fig[1, 2], target_projection = "+proj=wintri", coastlines = true, title = "wintri") surface!(ax1, lons, lats, field; shading = false, colormap = (:plasma, 0.45)) diff --git a/examples/rotating_earth.jl b/examples/rotating_earth.jl index 141b0f84..3258f1e8 100644 --- a/examples/rotating_earth.jl +++ b/examples/rotating_earth.jl @@ -5,12 +5,10 @@ destnode = Observable("+proj=ortho") fig = Figure() ga = GeoAxis( fig[1, 1], - coastlines = true, - dest = destnode, - lonlims = Makie.automatic + target_projection = destnode, ) -image!(-180..180, -90..90, rotr90(GeoMakie.earth()); interpolate = false) -hidedecorations!(ga) +image!(ga, -180..180, -90..90, rotr90(GeoMakie.earth()); interpolate = false) +hidedecorations!(ga) # TODO implement hidedecorations/spines record(fig, "rotating_earth_ortho.mp4"; framerate=30) do io for lon in -90:90 @@ -20,3 +18,4 @@ record(fig, "rotating_earth_ortho.mp4"; framerate=30) do io recordframe!(io) end end +# TODO this doesn't work! \ No newline at end of file diff --git a/examples/world_population.jl b/examples/world_population.jl index d24ec20b..f4aa800f 100644 --- a/examples/world_population.jl +++ b/examples/world_population.jl @@ -7,7 +7,7 @@ using Downloads source = "+proj=longlat +datum=WGS84" dest = "+proj=natearth2" -url = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/" +url = "https://raw.githack.com/nvkelso/natural-earth-vector/master/geojson/" land = Downloads.download(url * "ne_110m_land.geojson") land_geo = GeoJSON.read(read(land, String)) pop = Downloads.download(url * "ne_10m_populated_places_simple.geojson") @@ -17,8 +17,8 @@ begin fig = Figure(resolution = (1000,500)) ga = GeoAxis( fig[1, 1]; - source = source, - dest = dest + source_projection = source, + target_projection = dest ) ga.xticklabelsvisible[] = false diff --git a/src/GeoMakie.jl b/src/GeoMakie.jl index 60bfbbfa..adbe6848 100644 --- a/src/GeoMakie.jl +++ b/src/GeoMakie.jl @@ -45,6 +45,7 @@ export Proj export FileIO include("geoaxis.jl") +include("makie-axis.jl") export GeoAxis, datalims, datalims!, automatic diff --git a/src/geoaxis.jl b/src/geoaxis.jl index d31693b5..ba9ae08c 100644 --- a/src/geoaxis.jl +++ b/src/geoaxis.jl @@ -1,171 +1,399 @@ -using Makie: left, right, top, bottom -using Makie: height, width +const Rect2d = Rect2{Float64} + +Makie.@Block GeoAxis begin + # "The Scene of the GeoAxis, which holds all plots." + scene::Scene + # "Targeted limits in input space" + targetlimits::Observable{Rect2d} + # "Final limits in input space" + finallimits::Observable{Rect2d} + # Final limits in transformed space + transformedlimits::Observable{Rect2d} + # interaction stuff + mouseeventhandle::Makie.MouseEventHandle + scrollevents::Observable{Makie.ScrollEvent} + keysevents::Observable{Makie.KeysEvent} + interactions::Dict{Symbol, Tuple{Bool, Any}} + # "The plot elements of the axis - spines, ticks, labels, etc." + elements::Dict{Symbol, Any} + @attributes begin + # Geoaxis/crs stuff + "The default source CRS of input data" + source_projection = "+proj=longlat +datum=WGS84" + "The destination CRS for the axis" + target_projection = "+proj=eqearth" + "The number of points in all axis-decorating lines (effectively setting sampling density)" + npoints = 1_000 + + # appearance controls + "The set of fonts which text in the axis should use.s" + fonts = @inherit(:fonts, Makie.minimal_default.fonts) + "The axis title string." + title = "" + "The font family of the title." + titlefont = :bold + "The title's font size." + titlesize::Float64 = @inherit(:fontsize, 16f0) + "The gap between axis and title." + titlegap::Float64 = 4f0 + "Controls if the title is visible." + titlevisible::Bool = true + "The horizontal alignment of the title." + titlealign::Symbol = :center + "The color of the title" + titlecolor::RGBAf = @inherit(:textcolor, :black) + "The axis title line height multiplier." + titlelineheight::Float64 = 1 + "The axis subtitle string." + subtitle = "" + "The font family of the subtitle." + subtitlefont = :regular + "The subtitle's font size." + subtitlesize::Float64 = @inherit(:fontsize, 16f0) + "The gap between subtitle and title." + subtitlegap::Float64 = 0 + "Controls if the subtitle is visible." + subtitlevisible::Bool = true + "The color of the subtitle" + subtitlecolor::RGBAf = @inherit(:textcolor, :black) + "The axis subtitle line height multiplier." + subtitlelineheight::Float64 = 1 + + + "The xlabel string." + xlabel = "" + "The ylabel string." + ylabel = "" + "The font family of the xlabel." + xlabelfont = :regular + "The font family of the ylabel." + ylabelfont = :regular + "The color of the xlabel." + xlabelcolor::RGBAf = @inherit(:textcolor, :black) + "The color of the ylabel." + ylabelcolor::RGBAf = @inherit(:textcolor, :black) + "The font size of the xlabel." + xlabelsize::Float64 = @inherit(:fontsize, 16f0) + "The font size of the ylabel." + ylabelsize::Float64 = @inherit(:fontsize, 16f0) + "Controls if the xlabel is visible." + xlabelvisible::Bool = true + "Controls if the ylabel is visible." + ylabelvisible::Bool = true + "The padding between the xlabel and the ticks or axis." + xlabelpadding::Float64 = 3f0 + "The padding between the ylabel and the ticks or axis." + ylabelpadding::Float64 = 5f0 # xlabels usually have some more visual padding because of ascenders, which are larger than the hadvance gaps of ylabels + "The xlabel rotation in radians." + xlabelrotation = Makie.automatic + "The ylabel rotation in radians." + ylabelrotation = Makie.automatic + + "The x (longitude) ticks - can be a vector or a Makie tick finding algorithm." + xticks = Makie.automatic + "The y (latitude) ticks - can be a vector or a Makie tick finding algorithm." + yticks = Makie.automatic + "Format for x (longitude) ticks." + xtickformat = Makie.automatic + "Format for y (latitude) ticks." + ytickformat = Makie.automatic + "The font family of the xticklabels." + xticklabelfont = :regular + "The font family of the yticklabels." + yticklabelfont = :regular + "The color of xticklabels." + xticklabelcolor::RGBAf = @inherit(:textcolor, :black) + "The color of yticklabels." + yticklabelcolor::RGBAf = @inherit(:textcolor, :black) + "The font size of the xticklabels." + xticklabelsize::Float64 = @inherit(:fontsize, 16f0) + "The font size of the yticklabels." + yticklabelsize::Float64 = @inherit(:fontsize, 16f0) + "Controls if the xticklabels are visible." + xticklabelsvisible::Bool = true + "Controls if the yticklabels are visible." + yticklabelsvisible::Bool = true + "The space reserved for the xticklabels." + xticklabelspace::Union{Makie.Automatic, Float64} = Makie.automatic + "The space reserved for the yticklabels." + yticklabelspace::Union{Makie.Automatic, Float64} = Makie.automatic + "The space between xticks and xticklabels." + xticklabelpad::Float64 = 5f0 + "The space between yticks and yticklabels." + yticklabelpad::Float64 = 5f0 + "The counterclockwise rotation of the xticklabels in radians." + xticklabelrotation::Float64 = 0f0 + "The counterclockwise rotation of the yticklabels in radians." + yticklabelrotation::Float64 = 0f0 + "The horizontal and vertical alignment of the xticklabels." + xticklabelalign::Union{Makie.Automatic, Tuple{Symbol, Symbol}} = Makie.automatic + "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 + "The size of the ytick marks." + yticksize::Float64 = 6f0 + "Controls if the xtick marks are visible." + xticksvisible::Bool = true + "Controls if the ytick marks are visible." + yticksvisible::Bool = true + "The alignment of the xtick marks relative to the axis spine (0 = out, 1 = in)." + xtickalign::Float64 = 0f0 + "The alignment of the ytick marks relative to the axis spine (0 = out, 1 = in)." + ytickalign::Float64 = 0f0 + "The width of the xtick marks." + xtickwidth::Float64 = 1f0 + "The width of the ytick marks." + ytickwidth::Float64 = 1f0 + "The color of the xtick marks." + xtickcolor::RGBAf = RGBf(0, 0, 0) + "The color of the ytick marks." + ytickcolor::RGBAf = RGBf(0, 0, 0) + "The width of the axis spines." + spinewidth::Float64 = 1f0 + "Controls if the x grid lines are visible." + xgridvisible::Bool = true + "Controls if the y grid lines are visible." + ygridvisible::Bool = true + "The width of the x grid lines." + xgridwidth::Float64 = 1f0 + "The width of the y grid lines." + ygridwidth::Float64 = 1f0 + "The color of the x grid lines." + xgridcolor::RGBAf = RGBAf(0, 0, 0, 0.12) + "The color of the y grid lines." + ygridcolor::RGBAf = RGBAf(0, 0, 0, 0.12) + "The linestyle of the x grid lines." + xgridstyle = nothing + "The linestyle of the y grid lines." + ygridstyle = nothing + "Controls if minor ticks on the x axis are visible" + xminorticksvisible::Bool = false + "The alignment of x minor ticks on the axis spine" + xminortickalign::Float64 = 0f0 + "The tick size of x minor ticks" + xminorticksize::Float64 = 4f0 + "The tick width of x minor ticks" + xminortickwidth::Float64 = 1f0 + "The tick color of x minor ticks" + xminortickcolor::RGBAf = :black + "The tick locator for the x minor ticks" + xminorticks = IntervalsBetween(2) + "Controls if minor ticks on the y axis are visible" + yminorticksvisible::Bool = false + "The alignment of y minor ticks on the axis spine" + yminortickalign::Float64 = 0f0 + "The tick size of y minor ticks" + yminorticksize::Float64 = 4f0 + "The tick width of y minor ticks" + yminortickwidth::Float64 = 1f0 + "The tick color of y minor ticks" + yminortickcolor::RGBAf = :black + "The tick locator for the y minor ticks" + yminorticks = IntervalsBetween(2) + "Controls if the x minor grid lines are visible." + xminorgridvisible::Bool = false + "Controls if the y minor grid lines are visible." + yminorgridvisible::Bool = false + "The width of the x minor grid lines." + xminorgridwidth::Float64 = 1f0 + "The width of the y minor grid lines." + yminorgridwidth::Float64 = 1f0 + "The color of the x minor grid lines." + xminorgridcolor::RGBAf = RGBAf(0, 0, 0, 0.05) + "The color of the y minor grid lines." + yminorgridcolor::RGBAf = RGBAf(0, 0, 0, 0.05) + "The linestyle of the x minor grid lines." + xminorgridstyle = nothing + "The linestyle of the y minor grid lines." + yminorgridstyle = nothing + "Controls if the bottom axis spine is visible." + bottomspinevisible::Bool = true + "Controls if the left axis spine is visible." + leftspinevisible::Bool = true + "Controls if the top axis spine is visible." + topspinevisible::Bool = true + "Controls if the right axis spine is visible." + rightspinevisible::Bool = true + "The color of the bottom axis spine." + bottomspinecolor::RGBAf = :black + "The color of the left axis spine." + leftspinecolor::RGBAf = :black + "The color of the top axis spine." + topspinecolor::RGBAf = :black + "The color of the right axis spine." + rightspinecolor::RGBAf = :black + + # Layout observables + "The horizontal alignment of the block in its suggested bounding box." + halign = :center + "The vertical alignment of the block in its suggested bounding box." + valign = :center + "The width setting of the block." + width = Makie.Auto() + "The height setting of the block." + height = Makie.Auto() + "Controls if the parent layout can adjust to this block's width" + tellwidth::Bool = true + "Controls if the parent layout can adjust to this block's height" + tellheight::Bool = true + "The align mode of the block in its parent GridLayout." + alignmode = Makie.Inside() + + "The forced aspect ratio of the axis. `nothing` leaves the axis unconstrained, `DataAspect()` forces the same ratio as the ratio in data limits between x and y axis, `AxisAspect(ratio)` sets a manual ratio." + aspect = Makie.DataAspect() + autolimitaspect = nothing + + # old Axis stuff + "Controls if the y axis goes upwards (false) or downwards (true)" + yreversed::Bool = false + "Controls if the x axis goes rightwards (false) or leftwards (true)" + xreversed::Bool = false + "The relative margins added to the autolimits in x direction." + xautolimitmargin::Tuple{Float64,Float64} = (0f0, 0f0) + "The relative margins added to the autolimits in y direction." + yautolimitmargin::Tuple{Float64,Float64} = (0f0, 0f0) + "The limits that the user has manually set. They are reinstated when calling `reset_limits!` and are set to nothing by `autolimits!`. Can be either a tuple (xlow, xhigh, ylow, high) or a tuple (nothing_or_xlims, nothing_or_ylims). Are set by `xlims!`, `ylims!` and `limits!`." + limits = (nothing, nothing) + + + "The button for panning." + panbutton::Makie.Mouse.Button = Makie.Mouse.right + "The key for limiting panning to the x direction." + xpankey::Makie.Keyboard.Button = Makie.Keyboard.x + "The key for limiting panning to the y direction." + ypankey::Makie.Keyboard.Button = Makie.Keyboard.y + "The key for limiting zooming to the x direction." + xzoomkey::Makie.Keyboard.Button = Makie.Keyboard.x + "The key for limiting zooming to the y direction." + yzoomkey::Makie.Keyboard.Button = Makie.Keyboard.y + + "Locks interactive panning in the x direction." + xpanlock::Bool = false + "Locks interactive panning in the y direction." + ypanlock::Bool = false + "Locks interactive zooming in the x direction." + xzoomlock::Bool = false + "Locks interactive zooming in the y direction." + yzoomlock::Bool = false + "Controls if rectangle zooming affects the x dimension." + xrectzoom::Bool = true + "Controls if rectangle zooming affects the y dimension." + yrectzoom::Bool = true + "Do not set this - it's required for interop with Makie. Has no effect." + xscale = identity + "Do not set this - it's required for interop with Makie. Has no effect." + yscale = identity + end +end +Makie.can_be_current_axis(::GeoAxis) = true -""" - GeoAxis(fig_or_scene; kwargs...) → ax::Axis - -Create a modified `Axis` of the Makie.jl ecosystem. -All Makie.jl plotting functions work directly on `GeoAxis`, e.g., `scatter!(ax, x, y)`. -You can pass any keyword which `Axis` accepts, and manipulate it just like a -regular `Axis`. - -This is because it _is_ a regular `Axis`, using the interface you are already -familiar with, functions like `xlims!` and attributes like `ax.xticks`, etc. just work. - -`GeoAxis` is appropriate for geospatial plotting because it automatically transforms -all plotted data, given a user-defined map projection. See keyword arguments below -and examples in the online documentation. Longitude and latitude values in GeoMakie.jl -are always assumed to be **in degrees**. - -In order to automatically adjust the limits to your data, you can call `datalims!(ax)` -on any `GeoAxis`. - -In the call signature, `fig_or_scene` can be a standard figure location, e.g., -`fig[1,1]` as given in `Axis`. The keyword arguments decide the geospatial projection. - -## Keyword arguments - -* `source = "+proj=longlat +datum=WGS84", dest = "+proj=eqearth"`: These two keywords - configure the map projection to be used for the given field using Proj.jl. - See also online the section [Changing central longitude](@ref) for data that may not - span the (expected by default) longitude range from -180 to 180. -* `transformation = Proj.Transformation(source, dest, always_xy=true)`: Instead of - `source, dest`, you can directly use the Proj.jl package to define the projection. -* `lonlims = (-180, 180)`: The limits for longitude (x-axis). For automatic - determination, pass `lonlims=automatic`. -* `latlims = (-90, 90)`: The limits for latitude (y-axis). For automatic - determination, pass `latlims=automatic`. -* `coastlines = false`: Draw the coastlines of the world, from the Natural Earth dataset. -* `coastline_attributes = (;)`: Attributes that get passed to the `lines` call drawing the coastline. -* `line_density = 1000`: The number of points sampled per grid line. Do not set - this higher than 10,000 for performance and file size reasons.. -* `remove_overlapping_ticks = true`: Remove ticks which could overlap each other. - X-axis (longitude) ticks take priority over Y-axis (latitude) ticks. - -## Example - -```julia -using GeoMakie -fig = Figure() -ax = GeoAxis(fig[1,1]; coastlines = true) -image!(ax, -180..180, -90..90, rotr90(GeoMakie.earth()); interpolate = false) -el = scatter!(rand(-180:180, 5), rand(-90:90, 5); color = rand(RGBf, 5)) -fig - -``` -""" -function GeoAxis(args...; - source = "+proj=longlat +datum=WGS84", dest = "+proj=eqearth", - transformation = Proj.Transformation(Makie.to_value(source), Makie.to_value(dest), always_xy=true), - lonlims = (-180, 180), - latlims = (-90, 90), - coastlines = false, - coastline_attributes = (;label = "Coastlines",), - line_density = 1_000, - remove_overlapping_ticks = true, - # these are the axis keywords which we will merge in - xtickformat = _replace_if_automatic(Axis, :xtickformat, longitude_format), - ytickformat = _replace_if_automatic(Axis, :ytickformat, latitude_format), - xticks = LinearTicks(7), - yticks = LinearTicks(7), - xticklabelpad = 5.0, - yticklabelpad = 5.0, - # xticklabelalign = (:center, :center), - # yticklabelalign = (:center, :center), - alignmode = Outside(), - kw... - ) +function Makie.initialize_block!(axis::GeoAxis) - _transformation = Observable{Proj.Transformation}(Makie.to_value(transformation)) - Makie.Observables.onany(source, dest) do src, dst - _transformation[] = Proj.Transformation(src, dst; always_xy = true) - end - Makie.Observables.onany(transformation) do trans - _transformation[] = trans - end + scene = axis_setup!(axis) + setfield!(axis, :elements, Dict{Symbol,Any}()) - # Automatically determine limits! - # TODO: should we automatically verify limits - # or not? - if lonlims == Makie.automatic || latlims == Makie.automatic - axmin, axmax, aymin, aymax = find_transform_limits(_transformation[]) - end + ptrans = create_transform(axis.source_projection, axis.target_projection) - verified_lonlims = lonlims - if lonlims == Makie.automatic - verified_lonlims = axmin < axmax ? (axmin, axmax) : (axmax, axmin) - end - verified_latlims = latlims - if latlims == Makie.automatic - verified_latlims = aymin < aymax ? (aymin, aymax) : (aymax, aymin) - end - # Apply defaults - # Generate Axis instance - ax = Axis(args...; - aspect = DataAspect(), - xtickformat = xtickformat, - ytickformat = ytickformat, - xticks = xticks, - yticks = yticks, - limits = (verified_lonlims, verified_latlims), - xticklabelpad = xticklabelpad, - yticklabelpad = yticklabelpad, - # xticklabelalign = xticklabelalign, # these do not work with Axis because it wants a float - # yticklabelalign = yticklabelalign, # these do not work with Axis because it wants a float - alignmode = alignmode, - kw...) - - - # Set axis transformation - Makie.Observables.connect!(ax.scene.transformation.transform_func, _transformation) - - # Plot coastlines - coast_line = GeoMakie.coastlines() - coastplot = lines!(ax, coast_line; color = :black, coastline_attributes...) - translate!(coastplot, 0, 0, 99) # ensure they are on top of other plotted elements - xprot = ax.xaxis.protrusion[] - yprot = ax.yaxis.protrusion[] - if !coastlines - delete!(ax, coastplot) - end + draw_geoaxis!(axis, axis.target_projection, axis.elements, false) + + + subtitlepos = lift(scene.px_area, axis.titlegap, axis.titlealign, #=ax.xaxisposition, xaxis.protrusion=#; ignore_equal_values=true) do px_area, + titlegap, align#=, xaxisposition, xaxisprotrusion=# + + align_val = if align === :center + 0.5 + elseif align === :left + 0.0 + elseif align === :right + 1.0 + elseif align isa Real + @assert 0 ≤ align ≤ 1 + Float64(align) + else + error("Title align $align not supported.") + end + + yoffset = Makie.top(px_area) + titlegap - # Set the axis's native grid to always be invisible, and - # forward those updates to our observables. - # First we need to hijack the axis's protrusions and store them + return Point2f(px_area.origin[1] + px_area.widths[1] * align_val, yoffset) + end - hijacked_observables = Dict{Symbol, Observable}() - ## This macro is defined in `utils.jl` - @hijack_observable :xgridvisible - @hijack_observable :ygridvisible - @hijack_observable :xminorgridvisible - @hijack_observable :yminorgridvisible - @hijack_observable :xticksvisible - @hijack_observable :yticksvisible - # @hijack_observable :xticklabelsvisible - # @hijack_observable :yticklabelsvisible - @hijack_observable :topspinevisible - @hijack_observable :bottomspinevisible - @hijack_observable :leftspinevisible - @hijack_observable :rightspinevisible + titlealignnode = lift(axis.titlealign; ignore_equal_values=true) do align + (align, :bottom) + end + subtitleplot = text!( + axis.blockscene, subtitlepos, + text = axis.subtitle, + visible = axis.subtitlevisible, + fontsize = axis.subtitlesize, + align = titlealignnode, + font = axis.subtitlefont, + fonts = axis.fonts, + color = axis.subtitlecolor, + lineheight = axis.subtitlelineheight, + markerspace = :data, + inspectable = false) + + axis.elements[:subtitle] = subtitleplot + + titlepos = lift( + Makie.calculate_title_position, + scene.px_area, axis.titlegap, axis.subtitlegap, axis.titlealign, :bottom, nothing, axis.subtitlelineheight, axis, subtitleplot; + ignore_equal_values=true + ) - # WARNING: for now, we only accept xticks on the bottom - # and yticks on the left. + titleplot = text!( + axis.blockscene, titlepos, + text = axis.title, + visible = axis.titlevisible, + fontsize = axis.titlesize, + align = titlealignnode, + font = axis.titlefont, + fonts = axis.fonts, + color = axis.titlecolor, + lineheight = axis.titlelineheight, + markerspace = :data, + inspectable = false) + + axis.elements[:title] = titleplot + + update_protrusions_observable = Observable{Bool}(true) + + # on any update to any of the args which is not only a position change, update the protrusions! + onany(titleplot.text, titleplot.visible, titleplot.fontsize, titlealignnode, titleplot.font, titleplot.lineheight) do args... + update_protrusions_observable[] = true + end - draw_geoticks!(ax, hijacked_observables, line_density, remove_overlapping_ticks) + onany(subtitleplot.text, subtitleplot.visible, subtitleplot.fontsize, subtitleplot.font, subtitleplot.lineheight) do args... + update_protrusions_observable[] = true + end - ax.xaxis.protrusion[] = xprot - ax.yaxis.protrusion[] = yprot + # onany() +# + on(update_protrusions_observable; ignore_equal_values = false) do _notification_argument + px_area = scene.px_area[] + total_protrusion_bbox = reduce(union, Makie.boundingbox.(values(axis.elements))) + left_prot, bottom_prot = minimum(total_protrusion_bbox) + right_prot, top_prot = maximum(total_protrusion_bbox) + left_scene, bottom_scene = minimum(px_area) + right_scene, top_scene = maximum(px_area) + + axis.layoutobservables.protrusions[] = Makie.GridLayoutBase.RectSides(max.(0, (left_scene - left_prot, right_prot - right_scene, bottom_scene - bottom_prot, top_prot - top_scene))...) + end - return ax + return axis end -function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_overlapping_ticks) +# do the axis drawing + +function draw_geoaxis!(ax::GeoAxis, target_projection, elements, remove_overlapping_ticks) topscene = ax.blockscene scene = ax.scene - decorations = Dict{Symbol, Any}() + transformation = GeoMakie.create_transform(Observable("+proj=longlat +datum=WGS84"), target_projection) xgridpoints = Observable(Point2f[]) ygridpoints = Observable(Point2f[]) @@ -189,14 +417,10 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove xlimits = Observable((0.0f0, 0.0f0)) ylimits = Observable((0.0f0, 0.0f0)) - # Extract x and y ticklabel plots from the axis, to avoid duplication - - decorations[:xticklabel] = ax_xticklabel_plot = ax.blockscene.plots[10] - decorations[:yticklabel] = ax_yticklabel_plot = ax.blockscene.plots[15] # First we establish the spine points - lift(ax.finallimits, ax.xticks, ax.xtickformat, ax.yticks, ax.ytickformat, ax.xminorticks, ax.yminorticks, ax.scene.px_area, getproperty(ax.scene, :transformation).transform_func, ax.spinewidth, ax.xgridwidth, ax.ygridwidth) do limits, xticks, xtickformat, yticks, ytickformat, xminor, yminor, pxarea, _tfunc, spinewidth, xgridwidth, ygridwidth + lift(ax.finallimits, ax.xticks, ax.xtickformat, ax.yticks, ax.ytickformat, ax.xminorticks, ax.yminorticks, ax.scene.px_area, transformation, ax.npoints) do limits, xticks, xtickformat, yticks, ytickformat, xminor, yminor, pxarea, transform_func, npoints lmin = minimum(limits) lmax = maximum(limits) @@ -213,10 +437,10 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove _ytickpos_in_inputspace = Point2f.(xlimits[][1], _ytickvalues) # update but do not notify - xtickpoints.val = project_to_pixelspace(scene, _xtickpos_in_inputspace) .+ + xtickpoints.val = project_to_pixelspace(scene, transform_func, _xtickpos_in_inputspace) .+ Ref(Point2f(pxarea.origin)) - ytickpoints.val = project_to_pixelspace(scene, _ytickpos_in_inputspace) .+ + ytickpoints.val = project_to_pixelspace(scene, transform_func, _ytickpos_in_inputspace) .+ Ref(Point2f(pxarea.origin)) @@ -224,7 +448,7 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove ax.xticklabelsvisible[] = false else xtickpoints.val = xtickpoints.val .+ directional_pad.( - Ref(scene), Ref(limits), _xtickpos_in_inputspace, + Ref(scene), Ref(transform_func), Ref(limits), _xtickpos_in_inputspace, _xticklabels, Ref(Point2f(0, ax.xticklabelpad[])), ax.xticklabelsize[], ax.xticklabelfont[], ax.xticklabelrotation[] ) @@ -235,7 +459,7 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove ax.yticklabelsvisible[] = false else ytickpoints.val = ytickpoints.val .+ directional_pad.( - Ref(scene), Ref(limits), _ytickpos_in_inputspace, + Ref(scene), Ref(transform_func), Ref(limits), _ytickpos_in_inputspace, _yticklabels, Ref(Point2f(ax.yticklabelpad[], 0)), ax.yticklabelsize[], ax.yticklabelfont[], ax.yticklabelrotation[] ) @@ -256,14 +480,14 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove Makie.Observables.notify(xticklabels); Makie.Observables.notify(yticklabels) Makie.Observables.notify(xtickpoints); Makie.Observables.notify(ytickpoints) - xrange = LinRange(xlimits[]..., line_density) - yrange = LinRange(ylimits[]..., line_density) + xrange = LinRange(xlimits[]..., npoints) + yrange = LinRange(ylimits[]..., npoints) # first update the spine - topspinepoints[] = project_to_pixelspace(scene, Point2f.(xrange, ylimits[][2])) .+ (Point2f(pxarea.origin),) - btmspinepoints[] = project_to_pixelspace(scene, Point2f.(xrange, ylimits[][1])) .+ (Point2f(pxarea.origin),) - lftspinepoints[] = project_to_pixelspace(scene, Point2f.(xlimits[][1], yrange)) .+ (Point2f(pxarea.origin),) - rgtspinepoints[] = project_to_pixelspace(scene, Point2f.(xlimits[][2], yrange)) .+ (Point2f(pxarea.origin),) + topspinepoints[] = project_to_pixelspace(scene, transform_func, Point2f.(xrange, ylimits[][2])) .+ (Point2f(pxarea.origin),) + btmspinepoints[] = project_to_pixelspace(scene, transform_func, Point2f.(xrange, ylimits[][1])) .+ (Point2f(pxarea.origin),) + lftspinepoints[] = project_to_pixelspace(scene, transform_func, Point2f.(xlimits[][1], yrange)) .+ (Point2f(pxarea.origin),) + rgtspinepoints[] = project_to_pixelspace(scene, transform_func, Point2f.(xlimits[][2], yrange)) .+ (Point2f(pxarea.origin),) # TODO: remove when clip begins. # clippoints[] = vcat( @@ -276,47 +500,44 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove # now, the grid. Each visible "gridline" is separated from the next # by a `Point2f(NaN)`. The approach here allows us to avoid appending. # x first - _xgridpoints = fill(Point2f(NaN), (line_density+1) * length(_xtickvalues)) + _xgridpoints = fill(Point2f(NaN), (npoints+1) * length(_xtickvalues)) current_ind = 1 for x in _xtickvalues - _xgridpoints[current_ind:(current_ind+line_density-1)] = Point2f.(x, yrange) - current_ind += line_density + 1 + _xgridpoints[current_ind:(current_ind+npoints-1)] = Point2f.(x, yrange) + current_ind += npoints + 1 end # now y - _ygridpoints = fill(Point2f(NaN), (line_density+1) * length(_ytickvalues)) + _ygridpoints = fill(Point2f(NaN), (npoints+1) * length(_ytickvalues)) current_ind = 1 for y in _ytickvalues - _ygridpoints[current_ind:(current_ind+line_density-1)] = Point2f.(xrange, y) - current_ind += line_density + 1 + _ygridpoints[current_ind:(current_ind+npoints-1)] = Point2f.(xrange, y) + current_ind += npoints + 1 end - xgridpoints[] = _xgridpoints - ygridpoints[] = _ygridpoints + xgridpoints[] = project_to_pixelspace(scene, transform_func, _xgridpoints) .+ (Point2f(pxarea.origin),) + ygridpoints[] = project_to_pixelspace(scene, transform_func, _ygridpoints) .+ (Point2f(pxarea.origin),) # Do the same for minor ticks - _xminorgridpoints = fill(Point2f(NaN), (line_density+1) * length(_xminortickvalues)) + _xminorgridpoints = fill(Point2f(NaN), (npoints+1) * length(_xminortickvalues)) current_ind = 1 for x in _xminortickvalues - _xminorgridpoints[current_ind:(current_ind+line_density-1)] = Point2f.(x, yrange) - current_ind += line_density + 1 + _xminorgridpoints[current_ind:(current_ind+npoints-1)] = Point2f.(x, yrange) + current_ind += npoints + 1 end # now y - _yminorgridpoints = fill(Point2f(NaN), (line_density+1) * length(_yminortickvalues)) + _yminorgridpoints = fill(Point2f(NaN), (npoints+1) * length(_yminortickvalues)) current_ind = 1 for y in _yminortickvalues - _yminorgridpoints[current_ind:(current_ind+line_density-1)] = Point2f.(xrange, y) - current_ind += line_density + 1 + _yminorgridpoints[current_ind:(current_ind+npoints-1)] = Point2f.(xrange, y) + current_ind += npoints + 1 end - xminorgridpoints[] = _xminorgridpoints - yminorgridpoints[] = _yminorgridpoints - - ax_xticklabel_plot.align = (:center, :center) - ax_yticklabel_plot.align = (:center, :center) + xminorgridpoints[] = project_to_pixelspace(scene, transform_func, _xminorgridpoints) .+ (Point2f(pxarea.origin),) + yminorgridpoints[] = project_to_pixelspace(scene, transform_func, _yminorgridpoints) .+ (Point2f(pxarea.origin),) return 1 # Now, we've updated the entire axis. @@ -328,38 +549,38 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove # First, we clip our scene: - # decorations[:clip] = clip!(scene, clippoints) - # translate!(decorations[:clip], 0, 0, -9999) + # elements[:clip] = clip!(scene, clippoints) + # translate!(elements[:clip], 0, 0, -9999) # This makes the clip plot the first in the list of plots # insert!(scene.plots, 1, pop!(scene.plots)) # Now we plot the spines. # Make sure that the spines are plotted to the blockscene and not the scene, # so that they are not cropped! - decorations[:topspineplot] = lines!( + elements[:topspineplot] = lines!( topscene, topspinepoints; - visible = hijacked_observables[:topspinevisible], + visible = ax.topspinevisible, color = ax.topspinecolor, # linestyle = ax.spinestyle, linewidth = ax.spinewidth, ) - decorations[:btmspineplot] = lines!( + elements[:bottomspineplot] = lines!( topscene, btmspinepoints; - visible = hijacked_observables[:bottomspinevisible], + visible = ax.bottomspinevisible, color = ax.bottomspinecolor, # linestyle = ax.spinestyle, linewidth = ax.spinewidth, ) - decorations[:lftspineplot] = lines!( + elements[:leftspineplot] = lines!( topscene, lftspinepoints; - visible = hijacked_observables[:leftspinevisible], + visible = ax.leftspinevisible, color = ax.leftspinecolor, # linestyle = ax.spinestyle, linewidth = ax.spinewidth, ) - decorations[:rgtspineplot] = lines!( + elements[:rightspineplot] = lines!( topscene, rgtspinepoints; - visible = hijacked_observables[:rightspinevisible], + visible = ax.rightspinevisible, color = ax.rightspinecolor, # linestyle = ax.spinestyle, linewidth = ax.spinewidth, @@ -368,35 +589,35 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove # Now for the grids: - decorations[:xgridplot] = lines!( - scene, xgridpoints; - visible = hijacked_observables[:xgridvisible], + elements[:xgridplot] = lines!( + topscene, xgridpoints; + visible = ax.xgridvisible, color = ax.xgridcolor, linestyle = ax.xgridstyle, width = ax.xgridwidth, transparency=true, ) - decorations[:ygridplot] = lines!( - scene, ygridpoints; - visible = hijacked_observables[:ygridvisible], + elements[:ygridplot] = lines!( + topscene, ygridpoints; + visible = ax.ygridvisible, color = ax.ygridcolor, linestyle = ax.ygridstyle, width = ax.ygridwidth, transparency=true, ) - decorations[:xminorgridplot] = lines!( - scene, xminorgridpoints; - visible = hijacked_observables[:xminorgridvisible], + elements[:xminorgridplot] = lines!( + topscene, xminorgridpoints; + visible = ax.xminorgridvisible, color = ax.xminorgridcolor, linestyle = ax.xminorgridstyle, width = ax.xminorgridwidth, transparency=true, ) - decorations[:yminorgridplot] = lines!( - scene, yminorgridpoints; - visible = hijacked_observables[:yminorgridvisible], + elements[:yminorgridplot] = lines!( + topscene, yminorgridpoints; + visible = ax.yminorgridvisible, color = ax.yminorgridcolor, linestyle = ax.yminorgridstyle, width = ax.yminorgridwidth, @@ -408,84 +629,162 @@ function draw_geoticks!(ax::Axis, hijacked_observables, line_density, remove_ove textscene = ax.blockscene - # decorations[:xtickplot] = text!( - # textscene, - # xticklabels; - # markerspace = :pixel, - # visible = hijacked_observables[:xticklabelsvisible], - # position = xtickpoints, - # rotation = ax.xticklabelrotation, - # font = ax.xticklabelfont, - # fontsize = ax.xticklabelsize, - # color = ax.xticklabelcolor, - # align = (:center, :center), - # ) - # - # decorations[:ytickplot] = text!( - # textscene, - # yticklabels; - # markerspace = :pixel, - # visible = hijacked_observables[:yticklabelsvisible], - # position = ytickpoints, - # rotation = ax.yticklabelrotation, - # font = ax.yticklabelfont, - # fontsize = ax.yticklabelsize, - # color = ax.yticklabelcolor, - # align = (:center, :center), - # ) - - - # Currently, I hijack the axis text for this. However, I don't know what it would do - # to interaction times, hence why I have left the old code commented out above. - Makie.Observables.connect!(ax_xticklabel_plot[1], Makie.@lift tuple.($xticklabels, $xtickpoints)) - Makie.Observables.connect!(ax_yticklabel_plot[1], Makie.@lift tuple.($yticklabels, $ytickpoints)) + elements[:xticklabelplot] = text!( + textscene, + xticklabels; + markerspace = :pixel, + visible = ax.xticklabelsvisible, + position = xtickpoints, + rotation = ax.xticklabelrotation, + font = ax.xticklabelfont, + fontsize = ax.xticklabelsize, + color = ax.xticklabelcolor, + align = (:center, :center), + ) + + elements[:yticklabelplot] = text!( + textscene, + yticklabels; + markerspace = :pixel, + visible = ax.yticklabelsvisible, + position = ytickpoints, + rotation = ax.yticklabelrotation, + font = ax.yticklabelfont, + fontsize = ax.yticklabelsize, + color = ax.yticklabelcolor, + align = (:center, :center), + ) + + # Previously, I hijacked the axis text for this. However, I don't know what it wo÷ # For diagnostics only! - # scatter!(textscene, xtickpoints; visible = hijacked_observables[:xticklabelsvisible], color = :red, bordercolor=:black) - # scatter!(textscene, ytickpoints; visible = hijacked_observables[:yticklabelsvisible], color = :red, bordercolor=:black) + # scatter!(textscene, xtickpoints; visible = ax[:xticklabelsvisible], color = :red, bordercolor=:black) + # scatter!(textscene, ytickpoints; visible = ax[:yticklabelsvisible], color = :red, bordercolor=:black) # Finally, we translate these plots such that they are above the content. - translate!.(values(decorations), 0, 0, 100) + translate!.(values(elements), 0, 0, 100) # Set common attributes for all plots - setproperty!.(values(decorations), Ref(:inspectable), Ref(false)) - setproperty!.(values(decorations), Ref(:xautolimits), Ref(false)) - setproperty!.(values(decorations), Ref(:yautolimits), Ref(false)) + setproperty!.(values(elements), Ref(:inspectable), Ref(false)) + setproperty!.(values(elements), Ref(:xautolimits), Ref(false)) + setproperty!.(values(elements), Ref(:yautolimits), Ref(false)) + + # finally, make sure that lift runs again - for some reason, it doesn't work directly + notify(ax.transformedlimits) + notify(ax.finallimits) + + return nothing +end + +function Makie.xlims!(ax::GeoAxis, xlims) + if length(xlims) != 2 + error("Invalid xlims length of $(length(xlims)), must be 2.") + elseif xlims[1] == xlims[2] + error("Can't set x limits to the same value $(xlims[1]).") + end - return decorations + ax.limits.val = (xlims, ax.limits[][2]) + reset_limits!(ax, yauto = false) + nothing end +function Makie.ylims!(ax::GeoAxis, ylims) + if length(ylims) != 2 + error("Invalid ylims length of $(length(ylims)), must be 2.") + elseif ylims[1] == ylims[2] + error("Can't set x limits to the same value $(ylims[1]).") + end + + ax.limits.val = (ax.limits[][1], ylims) + reset_limits!(ax, xauto = false) + nothing +end -function _datalims_exclude(plot) - !(to_value(get(plot, :xautolimits, true)) || to_value(get(plot, :yautolimits, true))) || - !Makie.is_data_space(to_value(get(plot, :space, :data))) || - !to_value(get(plot, :visible, true)) + +""" + create_transform(dest::String, source::String) + +Creates a `Proj.Transformation` from the provided strings, with the parameter `always_xy` set to `true`. +""" +function create_transform(source::String, dest::String) + return Proj.Transformation(source, dest; always_xy=true) +end + +function create_transform(source::Observable, dest::Observable) + result = Observable{Any}() + return map!(create_transform, result, source, dest) +end + +function Makie.plot!( + axis::GeoAxis, P::Makie.PlotFunc, + attributes::Makie.Attributes, args...; kw_attributes...) + allattrs = merge(attributes, Attributes(kw_attributes)) + source = pop!(allattrs, :source, axis.source_projection) + transformfunc = create_transform(source, axis.target_projection) + trans = Transformation(transformfunc) + allattrs[:transformation] = trans + plt = Makie.plot!(axis.scene, P, allattrs, args...) + if Makie.is_open_or_any_parent(axis.scene) + reset_limits!(axis) + end + return plt +end + +function Makie.plot!(P::Type{<:Poly}, axis::GeoAxis, args...; kw_attributes...) + attributes = Makie.Attributes(kw_attributes) + source = pop!(attributes, :source, axis.source_projection) + transformfunc = create_transform(source, axis.target_projection) + arg = geomakie_transform(transformfunc, convert.(Observable, args)...) + plt = Makie.plot!(axis.scene, P, attributes, arg) + if Makie.is_open_or_any_parent(axis.scene) + reset_limits!(axis) + end + return plt end -# Applicable only to geoaxis -# in the future, once PolarAxis is implemented as an example, -# change this to `Makie.data_limits(ax::GeoAxis)` -function datalims(ax::Axis) - nplots = length(plots(ax.scene)) - - n_axisplots = if nplots ≥ 8 && - ax.scene.plots[2] isa Makie.Lines && - haskey(ax.scene.plots[2], :label) && - ax.scene.plots[2].label[] == "Coastlines" - 8 - else - 7 - end - return Makie.data_limits(ax.scene.plots[(n_axisplots+1):end], _datalims_exclude) +function Makie.plot!(P::Makie.PlotFunc, axis::GeoAxis, args...; kw_attributes...) + attributes = Makie.Attributes(kw_attributes) + Makie.plot!(axis, P, attributes, args...) +end + +function geomakie_transform(trans, points::AbstractVector{<: Point2}) + return Makie.apply_transform(trans, points) +end + +function geomakie_transform(trans, vec::AbstractVector) + return map(x-> geomakie_transform(trans, x), vec) +end +function geomakie_transform(trans, geom::GeoJSON.FeatureCollection) + return geomakie_transform(trans, convert_arguments(Poly, geom)[1]) end -function datalims!(ax::Axis) - lims = datalims(ax) - min = lims.origin[1:2] - max = lims.widths[1:2] .+ lims.origin[1:2] - xlims!(ax, min[1], max[1]) - ylims!(ax, min[2], max[2]) - return (min[1], max[1], min[2], max[2]) +function geomakie_transform(trans, geom) + return geomakie_transform(trans, convert_arguments(Poly, geom)[1]) end + +function geomakie_transform(trans, polygon::Polygon) + return Polygon( + geomakie_transform(trans, GeometryBasics.coordinates(polygon.exterior)), + geomakie_transform.((trans,), GeometryBasics.coordinates.(polygon.interiors)), + ) +end + +function geomakie_transform(trans, polygon::MultiPolygon) + return MultiPolygon(geomakie_transform.((trans,), polygon.polygons)) +end + +geomakie_transform(trans, polygons::AbstractArray{<: Polygon}) = geomakie_transform.((trans,), polygons) +geomakie_transform(trans, multipolygons::AbstractArray{<: MultiPolygon}) = geomakie_transform.((trans,), multipolygons) + + +function geomakie_transform(trans::Observable, obs...) + return map((args...) -> geomakie_transform(args...), trans, obs...) +end + + +# add docs for GeoAxis + +# Base.@doc GeoAxis """ +# """ \ No newline at end of file diff --git a/src/makie-axis.jl b/src/makie-axis.jl new file mode 100644 index 00000000..81f28c04 --- /dev/null +++ b/src/makie-axis.jl @@ -0,0 +1,358 @@ +# Anything in this file was directly taken from Makie/makielayout/blocks/axis.jl +# TODO, refactor axis.jl to make it easier to extend + +import Makie: xautolimits, yautolimits, autolimits, getxlimits, getylimits, getlimits + +# Lol, really GeometryBasics? +Base.convert(::Type{Rect2d}, x::Rect2) = Rect2d(x) + +# Makie.xautolimits(ga::GeoAxis) = (-180, 180) +# Makie.yautolimits(ga::GeoAxis) = (-90, 90) + +function geodefaultlimits(::Tuple{Nothing, Nothing}, source_projection, target_projection) + return Rect2d(-180, -90, 360, 180) +end + +function geodefaultlimits(limits::Tuple{NTuple{2, <: Real}, Nothing}, source_projection, target_projection) + return Rect2d(limits[1][1], -90, limits[1][2] - limits[1][1], 180) +end + +function geodefaultlimits(limits::Tuple{ Nothing, NTuple{2, <: Real}}, source_projection, target_projection) + return Rect2d(-180, limits[2][1], 360, limits[2][2] - limits[2][1]) +end + +function geodefaultlimits(limits::Tuple, source_projection, target_projection) + (xmin, xmax), (ymin, ymax) = limits + return Rect2d(xmin, ymin, xmax - xmin, ymax - ymin) +end + +function geodefaultlimits(limits::Rect{2, <: Real}, source_projection, target_projection) + return Rect2d(limits) +end + +function axis_setup!(axis::GeoAxis) + # initialize either with user limits, or pick defaults based on scales + # so that we don't immediately error + targetlimits = Observable{Rect2d}(geodefaultlimits(axis.limits[], axis.source_projection[], axis.target_projection[])) + finallimits = Observable{Rect2d}(targetlimits[]; ignore_equal_values=true) + transformedlimits = Observable{Rect2d}(finallimits[]; ignore_equal_values=true) + setfield!(axis, :targetlimits, targetlimits) + setfield!(axis, :finallimits, finallimits) + setfield!(axis, :transformedlimits, transformedlimits) + + axis_transform = create_transform(axis.source_projection, axis.target_projection) + + onany(finallimits, axis_transform) do finallims, tfunc + transformedlimits[] = Makie.apply_transform(tfunc, finallims) + end + notify(axis_transform) + + # set up transformed limits + # TODO get correct values rather than defaulting to all-Earth + + topscene = axis.blockscene + scenearea = Makie.sceneareanode!(axis.layoutobservables.computedbbox, transformedlimits, DataAspect()) + scene = Scene(topscene, px_area=scenearea) + axis.scene = scene + onany(Makie.update_axis_camera, camera(scene), scene.transformation.transform_func, transformedlimits, axis.xreversed, axis.yreversed) + notify(axis.layoutobservables.suggestedbbox) + Makie.register_events!(axis, scene) + on(axis.limits) do mlims + reset_limits!(axis) + end + onany(scene.px_area, targetlimits) do pxa, lims + Makie.adjustlimits!(axis) + end + fl = finallimits[] + notify(axis.limits) + if fl == finallimits[] + notify(finallimits) + end + return scene +end + +function Makie.autolimits!(ax::GeoAxis) + ax.limits[] = (nothing, nothing) + return +end + +""" + reset_limits!(axis; xauto = true, yauto = true) + +Resets the axis limits depending on the value of `axis.limits`. +If one of the two components of limits is nothing, +that value is either copied from the targetlimits if `xauto` or `yauto` is false, +respectively, or it is determined automatically from the plots in the axis. +If one of the components is a tuple of two numbers, those are used directly. +""" +function Makie.reset_limits!(axis::GeoAxis; xauto = true, yauto = true, zauto = true) + mlims = Makie.convert_limit_attribute(axis.limits[]) + + mxlims, mylims = mlims::Tuple{Any, Any} + + xlims = if isnothing(mxlims) || mxlims[1] === nothing || mxlims[2] === nothing + l = if xauto + xautolimits(axis) + else + minimum(axis.targetlimits[])[1], maximum(axis.targetlimits[])[1] + end + if mxlims === nothing + l + else + lo = mxlims[1] === nothing ? l[1] : mxlims[1] + hi = mxlims[2] === nothing ? l[2] : mxlims[2] + (lo, hi) + end + else + convert(Tuple{Float64, Float64}, tuple(mxlims...)) + end + ylims = if isnothing(mylims) || mylims[1] === nothing || mylims[2] === nothing + l = if yauto + yautolimits(axis) + else + minimum(axis.targetlimits[])[2], maximum(axis.targetlimits[])[2] + end + if mylims === nothing + l + else + lo = mylims[1] === nothing ? l[1] : mylims[1] + hi = mylims[2] === nothing ? l[2] : mylims[2] + (lo, hi) + end + else + convert(Tuple{Float64, Float64}, tuple(mylims...)) + end + + if !(xlims[1] <= xlims[2]) + error("Invalid x-limits as xlims[1] <= xlims[2] is not met for $xlims.") + end + if !(ylims[1] <= ylims[2]) + error("Invalid y-limits as ylims[1] <= ylims[2] is not met for $ylims.") + end + + axis.targetlimits[] = Makie.BBox(xlims..., ylims...) + + nothing +end + +function autolimits(axis::GeoAxis, dim::Integer) + # try getting x limits for the axis and then union them with linked axes + lims = Makie.getlimits(axis, dim) + dimsym = dim == 1 ? :x : :y + margin = getproperty(axis, Symbol(dimsym, :autolimitmargin))[] + if !isnothing(lims) + lims = Makie.expandlimits(lims, margin[1], margin[2], identity) + end + + # if no limits have been found, use the targetlimits directly + if isnothing(lims) + lims = Makie.limits(axis.targetlimits[], dim) + end + return lims +end + +xautolimits(axis::GeoAxis) = autolimits(axis, 1) +yautolimits(axis::GeoAxis) = autolimits(axis, 2) + + +function getlimits(la::GeoAxis, dim) + # find all plots that don't have exclusion attributes set + # for this dimension + if !(dim in (1, 2)) + error("Dimension $dim not allowed. Only 1 or 2.") + end + + function exclude(plot) + # only use plots with autolimits = true + to_value(get(plot, dim == 1 ? :xautolimits : :yautolimits, true)) || return true + # only if they use data coordinates + Makie.is_data_space(to_value(get(plot, :space, :data))) || return true + # only use visible plots for limits + return !to_value(get(plot, :visible, true)) + end + # get all data limits, minus the excluded plots + boundingbox = Makie.data_limits(la.scene, exclude) + # if there are no bboxes remaining, `nothing` signals that no limits could be determined + Makie.isfinite_rect(boundingbox) || return nothing + + # otherwise start with the first box + mini, maxi = minimum(boundingbox), maximum(boundingbox) + return (mini[dim], maxi[dim]) +end + +getxlimits(la::GeoAxis) = getlimits(la, 1) +getylimits(la::GeoAxis) = getlimits(la, 2) + + +function Makie.RectangleZoom(f::Function, ax::GeoAxis; kw...) + r = Makie.RectangleZoom(f; kw...) + selection_vertices = lift(Makie._selection_vertices, Observable(ax.scene), ax.finallimits, r.rectnode) + # manually specify correct faces for a rectangle with a rectangle hole inside + 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, + inspectable=false, visible=r.active, transparency=true) + # translate forward so selection mesh and frame are never behind data + translate!(mesh, 0, 0, 100) + return r +end + +function Makie.RectangleZoom(ax::GeoAxis; kw...) + return Makie.RectangleZoom(ax; kw...) do newlims + if !(0 in widths(newlims)) + ax.targetlimits[] = newlims + end + return + end +end + +Makie.interactions(ax::GeoAxis) = ax.interactions + +Makie.timed_ticklabelspace_reset(ax::GeoAxis, reset_timer::Ref, + prev_xticklabelspace::Ref, prev_yticklabelspace::Ref, threshold_sec::Real) = nothing + +function Makie.update_state_before_display!(ax::GeoAxis) + reset_limits!(ax) + return +end + + + +# EVENTS + +function Makie.process_interaction(r::Makie.RectangleZoom, event::MouseEvent, ax::GeoAxis) + + # TODO: actually, the data from the mouse event should be transformed already + # but the problem is that these mouse events are generated all the time + # and outside of log axes, you would quickly run into domain errors + transf = Makie.transform_func(ax) + inv_transf = Makie.inverse_transform(transf) + + if isnothing(inv_transf) + @warn "Can't rectangle zoom without inverse transform" maxlog = 1 + # TODO, what can we do without inverse? + return Consume(false) + end + + if event.type === MouseEventTypes.leftdragstart + data = Makie.apply_transform(inv_transf, event.data) + prev_data = Makie.apply_transform(inv_transf, event.prev_data) + + r.from = prev_data + r.to = data + r.rectnode[] = Makie._chosen_limits(r, ax) + r.active[] = true + return Consume(true) + + elseif event.type === MouseEventTypes.leftdrag + # clamp mouse data to shown limits + rect = Makie.apply_transform(transf, ax.finallimits[]) + data = Makie.apply_transform(inv_transf, Makie.rectclamp(event.data, rect)) + + r.to = data + r.rectnode[] = Makie._chosen_limits(r, ax) + return Consume(true) + + elseif event.type === MouseEventTypes.leftdragstop + try + r.callback(r.rectnode[]) + catch e + @warn "error in rectangle zoom" exception = e + end + r.active[] = false + return Consume(true) + end + + return Consume(false) +end + +function Makie.process_interaction(r::Makie.RectangleZoom, event::KeysEvent, ax::GeoAxis) + r.restrict_y = Keyboard.x in event.keys + r.restrict_x = Keyboard.y in event.keys + r.active[] || return Consume(false) + + r.rectnode[] = Makie._chosen_limits(r, ax) + return Consume(true) +end + +function Makie.process_interaction(l::Makie.LimitReset, event::MouseEvent, ax::GeoAxis) + + if event.type === MouseEventTypes.leftclick + if ispressed(ax.scene, Keyboard.left_control) + if ispressed(ax.scene, Keyboard.left_shift) + autolimits!(ax) + else + reset_limits!(ax) + end + return Consume(true) + end + end + + return Consume(false) +end + +function Makie.process_interaction(s::Makie.ScrollZoom, event::Makie.ScrollEvent, ax::GeoAxis) + # use vertical zoom + zoom = event.y + + tlimits = ax.targetlimits + xzoomlock = ax.xzoomlock + yzoomlock = ax.yzoomlock + xzoomkey = ax.xzoomkey + yzoomkey = ax.yzoomkey + + scene = ax.scene + e = Makie.events(scene) + cam = Makie.camera(scene) + + if zoom != 0 + pa = Makie.pixelarea(scene)[] + + z = (1.0f0 - s.speed)^zoom + + mp_axscene = Vec4f((e.mouseposition[] .- pa.origin)..., 0, 1) + + # first to normal -1..1 space + mp_axfraction = (cam.pixel_space[]*mp_axscene)[Vec(1, 2)] .* + # now to 1..-1 if an axis is reversed to correct zoom point + (-2 .* ((ax.xreversed[], ax.yreversed[])) .+ 1) .* + # now to 0..1 + 0.5 .+ 0.5 + + xscale = ax.xscale[] + yscale = ax.yscale[] + + transf = (xscale, yscale) + tlimits_trans = Makie.apply_transform(transf, tlimits[]) + + xorigin = tlimits_trans.origin[1] + yorigin = tlimits_trans.origin[2] + + xwidth = tlimits_trans.widths[1] + ywidth = tlimits_trans.widths[2] + + newxwidth = xzoomlock[] ? xwidth : xwidth * z + newywidth = yzoomlock[] ? ywidth : ywidth * z + + newxorigin = xzoomlock[] ? xorigin : xorigin + mp_axfraction[1] * (xwidth - newxwidth) + newyorigin = yzoomlock[] ? yorigin : yorigin + mp_axfraction[2] * (ywidth - newywidth) + + newrect_trans = if ispressed(scene, xzoomkey[]) + Rect2d(newxorigin, yorigin, newxwidth, ywidth) + elseif ispressed(scene, yzoomkey[]) + Rect2d(xorigin, newyorigin, xwidth, newywidth) + else + Rect2d(newxorigin, newyorigin, newxwidth, newywidth) + end + + inv_transf = Makie.inverse_transform(transf) + tlimits[] = Makie.apply_transform(inv_transf, newrect_trans) + end + + # NOTE this might be problematic if if we add scrolling to something like Menu + return Consume(true) +end + + +Makie.transformation(ax::GeoAxis) = Makie.transformation(ax.scene) diff --git a/src/utils.jl b/src/utils.jl index c929c9d7..aa992642 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -39,11 +39,16 @@ end function Makie.apply_transform(f::Proj.Transformation, r::Rect2{T}) where {T} xmin, ymin = minimum(r) xmax, ymax = maximum(r) + try (umin, umax), (vmin, vmax) = Proj.bounds(f, (xmin,xmax), (ymin,ymax)) return Rect(Vec2(T(umin), T(vmin)) ./ PROJ_RESCALE_FACTOR, Vec2(T(umax-umin), T(vmax-vmin)) ./ PROJ_RESCALE_FACTOR) + catch e + @show r + rethrow(e) + end end function Makie.inverse_transform(trans::Proj.Transformation) @@ -92,21 +97,6 @@ function find_transform_limits(ptrans; lonrange = (-180, 180), latrange = (-90, return (min[1], max[1], min[2], max[2]) end -# This is the code for the function body of `apply_transform(f::Proj4.Transformation, r::Rect2)` once Proj4.jl is renamed to Proj.jl -# out_xmin = Ref{Float64}(0.0) -# out_ymin = Ref{Float64}(0.0) -# out_xmax = Ref{Float64}(0.0) -# out_ymax = Ref{Float64}(0.0) -# try -# Proj.proj_trans_bounds(C_NULL, f.pj, Proj.PJ_FWD, minimum(r)..., maximum(r)..., out_xmin, out_ymin, out_xmax, out_ymax, N) -# catch e -# @show r -# @show out_xmin[] out_xmax[] out_ymin[] out_ymax[] -# rethrow(e) -# end -# -# return Rect2{T}((out_xmin[], out_xmax[] - out_xmin[]), (out_ymin[], out_ymax[] - out_ymin[])) - ############################################################ # # @@ -194,7 +184,7 @@ function _replace_if_automatic(typ::Type{T}, attribute::Symbol, auto) where T end # Project any point to coordinates in pixel space -function project_to_pixelspace(scene, point::Point{N, T}) where {N, T} +function project_to_pixelspace(scene, transform_func, point::Point{N, T}) where {N, T} @assert N ≤ 3 return Point{N, T}( Makie.project( @@ -204,14 +194,14 @@ function project_to_pixelspace(scene, point::Point{N, T}) where {N, T} :data, :pixel, # apply the transform to go from inputspace to dataspace Makie.apply_transform( - scene.transformation.transform_func[], + transform_func, point ) ) ) end -function project_to_pixelspace(scene, points::AbstractVector{Point{N, T}}) where {N, T} +function project_to_pixelspace(scene, transform_func, points::AbstractVector{Point{N, T}}) where {N, T} Point{N, T}.( Makie.project.( # obtain the camera of the Scene which will project to its screenspace @@ -220,7 +210,7 @@ function project_to_pixelspace(scene, points::AbstractVector{Point{N, T}}) where Ref(:data), Ref(:pixel), # apply the transform to go from inputspace to dataspace Makie.apply_transform( - scene.transformation.transform_func[], + transform_func, points ) ) @@ -244,10 +234,10 @@ function rotmat(θ) return Mat{2, 2}(cos(θ), sin(θ), -sin(θ), cos(θ)) end # Direction finder - find how to displace the tick so that it is out of the axis -function directional_pad(scene, limits, tickcoord_in_inputspace, ticklabel::AbstractString, tickpad, ticksize, tickfont, tickrotation; ds = 0.01) +function directional_pad(scene, transform_func, limits, tickcoord_in_inputspace, ticklabel::AbstractString, tickpad, ticksize, tickfont, tickrotation; ds = 0.01) # Define shorthand functions for dev purposes - these can be removed before release - tfunc = x -> Makie.apply_transform(scene.transformation.transform_func[], x) - inv_tfunc = x -> Makie.apply_transform(Makie.inverse_transform(scene.transformation.transform_func[]), x) + tfunc = Base.Fix1(Makie.apply_transform, transform_func) + inv_tfunc = Base.Fix1(Makie.apply_transform, Makie.inverse_transform(transform_func)) # convert tick coordinate to dataspace tickcoord_in_dataspace = tfunc(tickcoord_in_inputspace) # determine direction to go in order to stay inbounds. @@ -259,7 +249,7 @@ function directional_pad(scene, limits, tickcoord_in_inputspace, ticklabel::Abst # multiply by the sign in order to have them going outwards at any point Σp = sign(sum(Δs)) * inv_tfunc(tickcoord_in_dataspace + Δs) # project back to pixel space - pixel_Δx, pixel_Δy = project_to_pixelspace(scene, Σp) - project_to_pixelspace(scene, tickcoord_in_inputspace) + pixel_Δx, pixel_Δy = project_to_pixelspace(scene, transform_func, Σp) - project_to_pixelspace(scene, transform_func, tickcoord_in_inputspace) # invert direction - the vectors were previously facing the inside, # now they will face outside . dx = -pixel_Δx diff --git a/test/runtests.jl b/test/runtests.jl index 3da347d4..d446f44f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -13,7 +13,8 @@ Makie.set_theme!(Theme( field = [exp(cosd(l)) + 3(y/90) for l in lons, y in lats] fig = Figure() - ax = GeoAxis(fig[1,1], coastlines=true) + ax = GeoAxis(fig[1,1]) + lines!(ax, GeoMakie.coastlines(); color = :black) el = surface!(ax, lons, lats, field; shading = false) @test true # display(fig)