diff --git a/src/montage.jl b/src/montage.jl index 46719f7ce..75376b1f5 100644 --- a/src/montage.jl +++ b/src/montage.jl @@ -1,9 +1,68 @@ -export montage +# ------------------------------------------------------------------------------------------ +# Shared helpers used by the montage methods +# ------------------------------------------------------------------------------------------ + +"""Set up the frame/B/D options in `d` for montage panels.""" +function _montage_setup_frame!(d::Dict, frame::String, opt_B::String="") + if frame == "0" + d[:par] = (MAP_FRAME_PEN="0",); d[:frame] = frame + elseif frame !== "" + d[:B] = "0"; d[:par] = (:MAP_FRAME_PEN, parse_pen(frame)) + end + if opt_B !== "" + d[:B] = opt_B + elseif !haskey(d, :B) && frame == "" + d[:D] = true; d[:B] = "+n" + end +end + +"""Convert `divlines` kwarg to a `+w` suffix string for panels_size.""" +function _montage_divlines!(d::Dict, divlines)::String + (divlines === false || divlines == 0) && return "" + d[:divlines] = divlines + dvl = add_opt_pen(d, [:divlines]) + return dvl !== "" ? "+w" * dvl : "" +end + +"""Format `panels_size` (scalar or tuple) into a string, appending optional divlines suffix.""" +function _montage_panels_size_str(panels_size, dvl::String="")::String + if isa(panels_size, Tuple) + return string(panels_size[1], "/", panels_size[2], dvl) + else + return string(panels_size, dvl) + end +end + +"""Auto-adjust subplot margins for titles, colorbars, and annotations.""" +function _montage_adjust_margins!(d::Dict, titles, colorbar, cbar, margins::String) + if margins == "0.0" + dy = !isempty(titles) ? 0.5 : 0.0 + (cbar !== false) && (dy += 0.6) + dx = (get(d, :B, "+n") != "+n") && + (colorbar == true || colorbar == :right || + (isa(cbar, String) && contains(cbar, "RM"))) ? 0.90 : 0.38 + (dx != 0 || dy != 0) && (d[:M] = "0.38c/$(dx)c/$(dy)c/0") + else + d[:M] = margins + end + d[:par] = (MAP_TITLE_OFFSET="0p", FONT_TITLE="auto,Helvetica,black") +end + +"""Compute panels_size string from images or explicit value. Returns the string to use.""" +function _montage_panels_size!(d::Dict, images, panels_size::String, ncols::Int)::String + if panels_size == "" + _imgs = isa(images, AbstractVector) ? images : [images] + widths, heights = subplot_panel_sizes(_imgs, ncols=ncols) + return arg2str(widths,',') * "/" * arg2str(heights,',') + else + return panels_size + end +end # ------------------------------------------------------------------------------------------ """ - montage(images; grid=nothing, panels_size=nothing, margins="0.0", title=nothing, - titles=nothing, frame=nothing, indices=nothing, show=true, noR=false, kw...) + montage(images; grid=(0,0), panels_size="", margins="0.0", title="", + titles=String[], frame="", indices=Int[], show=true, noR=false, kw...) Display multiple images or grids arranged in a grid layout using GMT's subplot machinery. Panel sizes are automatically computed from the aspect ratios of the input images @@ -13,15 +72,15 @@ Panel sizes are automatically computed from the aspect ratios of the input image - `images`: Vector of GMTimage/GMTgrid objects or file name strings. ### Keywords -- `grid`: Tuple `(nrows, ncols)` specifying grid dimensions. Default: approximately square. -- `panels_size`: Panel size in cm — a scalar for square panels, a tuple `(w, h)`, or - a pre-formatted string `"w1,w2,.../h1,h2,..."`. Default: auto from aspect ratios. +- `grid`: Tuple `(nrows, ncols)` specifying grid dimensions. Use `(0,0)` for auto. Default: `(0,0)`. +- `panels_size`: Panel size — a scalar, a tuple `(w, h)`, or a pre-formatted string. + Use `""` for auto from aspect ratios. Default: `""`. - `margins`: Gap between panels (GMT subplot margins syntax). Default: `"0.0"`. -- `title`: Overall figure title string. -- `titles`: Vector of strings with individual panel titles. +- `title`: Overall figure title string. Default: `""` (no title). +- `titles`: Vector of panel title strings. Default: `String[]` (no titles). - `frame`: Frame style for panels. Use `"0"` for invisible frame outline, or a pen - specification. Default: no frame (`-D`). -- `indices`: Vector of integer indices to select a subset of `images`. + specification. Default: `""` (no frame, `-D`). +- `indices`: Vector of integer indices to select a subset of `images`. Default: `Int[]` (all). - `noR`: If `true`, skip passing the `-R` region from the image metadata. - `show`: Display the result (`true`) or keep the PS file open (`false`). Default: `true`. @@ -38,60 +97,235 @@ montage(imgs, grid=(2,2), panels_size=5) See also: `subplot`, `subplot_panel_sizes` """ -function montage(images; grid=nothing, panels_size=nothing, margins="0.0", - title=nothing, titles=nothing, frame=nothing, indices=nothing, - show::Bool=true, noR::Bool=false, kw...) +function montage(images; kw...) + d = KW(kw) + _montage_images(images, d) +end - (indices !== nothing) && (images = images[indices]) # Apply indices selection +function _montage_images(images, d::Dict) + indices = pop!(d, :indices, Int[])::Vector{Int} + !isempty(indices) && (images = images[indices]) ((n = length(images)) == 0) && error("No images to display") - nrows, ncols = _montage_grid_size(n, grid) # Calculate grid dimensions - d = KW(kw) + grid = pop!(d, :grid, (0,0)) + margins = string(pop!(d, :margins, "0.0"))::String + title = string(pop!(d, :title, ""))::String + titles = pop!(d, :titles, String[]) + frame = string(pop!(d, :frame, ""))::String + _show = pop!(d, :show, true)::Bool + noR = pop!(d, :noR, false)::Bool + ps_val = pop!(d, :panels_size, "") # Can be number, tuple, or string + + nrows, ncols = _montage_grid_size(n, grid) + d[:grid] = (nrows, ncols) d[:margin] = margins - (frame == "0") ? (d[:par] = (MAP_FRAME_PEN="0",); d[:frame] = frame) : - ((frame !== nothing) && (d[:B] ="0"; d[:par] = (:MAP_FRAME_PEN,parse_pen(frame)))) - - (frame === nothing) && (d[:D] = true; d[:B] ="+n") # No frames around panels and no space for the unexisting annots/ticks - (title !== nothing) && (d[:title] = title) + _montage_setup_frame!(d, frame) + (title !== "") && (d[:title] = title) - if (panels_size === nothing) - if (panels_size == :squares) - d[:panels_size] = max(3, min(8, 18 / max(nrows, ncols))) - else - widths, heights = subplot_panel_sizes(images, ncols=ncols) - d[:panels_size] = arg2str(widths,',') * "/" * arg2str(heights,',') # ((Tuple(widths), Tuple(heights)) - end + # Build panels_size string + if ps_val == "" || ps_val === nothing + ps_str = _montage_panels_size!(d, images, "", ncols) + elseif isa(ps_val, Tuple) + ps_str = string(ps_val[1], "/", ps_val[2]) else - d[:panels_size] = panels_size + ps_str = string(ps_val) end + d[:panels_size] = ps_str + + Vd = get(d, :Vd, 0) + + # Save and remove keys that subplot doesn't understand but viz needs + _has_cpt = haskey(d, :C) + saved_cpt = _has_cpt ? pop!(d, :C) : GMTcpt() - Vd = get(d, :Vd, 0) # Get the Vd option that will be consumed by subplot\ + subplot("", false, d) + d = CTRL.pocket_d[1] - subplot("", false, d) # Create subplot (since we already have the Dict pass it directly) - d = CTRL.pocket_d[1] # Fetch options not consumed by subplot. + _has_cpt && (d[:C] = saved_cpt) - # Plot each ... input + # Plot each input d[:J] = "x?" k = 0; n_inputs = length(images) for row in 1:nrows for col in 1:ncols ((k += 1) > n_inputs) && break - panel_title = (titles !== nothing && k <= length(titles)) ? titles[k] : nothing + panel_title = (!isempty(titles) && k <= length(titles)) ? titles[k] : nothing opt_R = isa(images[k], GItype) ? (noR ? "" : getR(images[k])) : "" viz(images[k]; panel=(row, col), title=panel_title, R=(opt_R !== "" ? opt_R : nothing), Vd=Vd, show=false, d...) end end - subplot(show ? :show : :end) # End subplot + subplot(_show ? :show : :end) return nothing end # ---------------------------------------------------------------------------------------------------------- """ - montage(D::Vector{<:GMTdataset}; grid=nothing, panels_size=nothing, margins=nothing, + montage(GI; grid=(0,0), panels_size="", margins="0.0", title="", + titles="", frame="", indices=Int[], cmap=nothing, + cmap_mode="same", colorbar=false, divlines=false, show=true, kw...) + +Display the layers of a 3D grid or image cube arranged in a grid layout. + +Each layer along the third dimension is extracted via `slicecube` and plotted in its own +panel using `grdimage` inside a GMT `subplot`. + +### Arguments +- `GI`: A 3D `GMTgrid` or `GMTimage` cube (must have at least 2 layers). + +### Keywords +- `grid`: Tuple `(nrows, ncols)` specifying the panel grid dimensions. Use `(0,0)` for auto. +- `panels_size`: Panel size in cm — a scalar, a tuple `(w, h)`, or a pre-formatted string. + Default: auto-computed from the cube's aspect ratio. +- `margins`: Gap between panels (GMT subplot margins syntax). Default: `"0.0"`. +- `title`: Overall figure title string. +- `titles`: Vector of per-panel title strings, or `"auto"` to derive titles from the cube's + `names` field or `v` (vertical coordinate) values. +- `frame`: Frame style for panels (e.g. `"0"` for invisible outline). Default: no frame. +- `indices`: Vector of integer indices to select a subset of layers. +- `cmap`: Color palette — a colormap name (e.g. `"turbo"`), a `GMTcpt` object, or `nothing` + for the GMT default colormap. +- `cmap_mode`: `"same"` (default) uses a single CPT computed from the cube's global min/max. + `"individual"` computes a separate CPT per layer, each scaled to that layer's own range. +- `colorbar`: Add a colorbar to each panel. `false` (default, no colorbar), `true` (right side), + or a GMT colorbar position string/NamedTuple. +- `divlines`: Draw dividing lines between panels. E.g. `divlines=0.5` or `divlines=(1,:red)`. +- `show`: Display the result. Default: `true`. + +### Examples +```julia +G = gmtread("cube.nc", layers=:all) +montage(G) + +montage(G, grid=(2,3), titles="auto") + +montage(G, cmap="turbo", colorbar=true) + +montage(G, cmap="turbo", cmap_mode=:individual, colorbar=true) + +montage(G, indices=[1,3,5], divlines=(0.5,:red)) +``` + +See also: `slicecube`, `imshow`, `subplot` +""" +function montage(GI::GItype; kw...) + d = KW(kw) + _montage_cube(GI, d) +end + +function _montage_cube(GI::GItype, d::Dict) + n_levels = size(GI, 3) + (n_levels < 2) && (@warn("Input has only one layer, nothing to montage."); return nothing) + + indices = pop!(d, :indices, Int[])::Vector{Int} + grid = pop!(d, :grid, (0,0)) + margins = string(pop!(d, :margins, "0.0"))::String + title = string(pop!(d, :title, ""))::String + titles = pop!(d, :titles, "") + frame = string(pop!(d, :frame, ""))::String + cmap = pop!(d, :cmap, nothing) + cmap_mode = string(pop!(d, :cmap_mode, "same"))::String + colorbar = pop!(d, :colorbar, false) + divlines = pop!(d, :divlines, false) + _show = pop!(d, :show, true)::Bool + ps_val = pop!(d, :panels_size, "") + + layers = !isempty(indices) ? indices : collect(1:n_levels) + n_layers = length(layers) + + # Build panel titles + _titles = String[] + if isa(titles, AbstractVector) && !isempty(titles) + _titles = titles + elseif isa(titles, AbstractString) && titles !== "" + if lowercase(titles) == "auto" + if (!isempty(GI.names) && !all(GI.names .== "")) _titles = GI.names[layers] + elseif (isa(GI, GMTgrid) && !isempty(GI.v)) _titles = string.(GI.v[layers]) + else _titles = string.(layers) + end + else + _titles = [titles] + end + end + + # Handle cmap and cmap_mode + individual = lowercase(cmap_mode) == "individual" + if (!individual) + if (cmap === nothing) + d[:C] = makecpt(GI.range[5], GI.range[6]) + elseif isa(cmap, GMTcpt) + d[:C] = cmap + else + d[:C] = makecpt(C=cmap, range=(GI.range[5], GI.range[6])) + end + end + _indiv_cmap = cmap + + # Compute a uniform panels_size from the cube's aspect ratio if not provided + nrows, ncols = _montage_grid_size(n_layers, grid) + if ps_val == "" || ps_val === nothing + W, H = getsize(GI) + aspect = H / W + pw = max(3, min(8, 18 / max(nrows, ncols))) + panels_size = (pw, round(pw * aspect, digits=2)) + elseif isa(ps_val, Tuple) + panels_size = ps_val + else + panels_size = ps_val + end + + dvl = _montage_divlines!(d, divlines) + + # Handle colorbar + (colorbar != false) && (d[:colorbar] = colorbar) + + # Extract 2D slices + slices = [slicecube(GI, layers[k]) for k in 1:n_layers] + + ps_str = _montage_panels_size_str(panels_size, dvl) + + if (!individual) + # Delegate to _montage_images via the Dict path + d[:grid] = grid; d[:panels_size] = ps_str; d[:margins] = margins + d[:title] = title; d[:titles] = _titles; d[:frame] = frame; d[:show] = _show + _montage_images(slices, d) + else + # For individual CPTs we need to reset CURRENT_CPT between panels + d[:grid] = (nrows, ncols) + _montage_setup_frame!(d, frame) + _montage_adjust_margins!(d, _titles, colorbar, colorbar, margins) + (title !== "") && (d[:title] = title) + d[:panels_size] = ps_str + saved_cbar = haskey(d, :colorbar) ? pop!(d, :colorbar) : false + Vd = get(d, :Vd, 0) + subplot("", false, d) + d = CTRL.pocket_d[1] + d[:J] = "x?" + (saved_cbar !== false) && (d[:colorbar] = saved_cbar) + k = 0 + for row in 1:nrows, col in 1:ncols + ((k += 1) > n_layers) && break + CURRENT_CPT[] = GMTcpt() # Force a new CPT for each layer + panel_title = (!isempty(_titles) && k <= length(_titles)) ? _titles[k] : nothing + zmin, zmax = slices[k].range[5], slices[k].range[6] + if (zmin == zmax) + d[:C] = makecpt(zmin - 1, zmax + 1) + elseif (_indiv_cmap !== nothing) + isa(_indiv_cmap, GMTcpt) ? (d[:C] = _indiv_cmap) : (d[:C] = makecpt(C=_indiv_cmap, range=(zmin, zmax))) + end + viz(slices[k]; panel=(row, col), title=panel_title, R=getR(slices[k]), Vd=Vd, show=false, d...) + haskey(d, :C) && delete!(d, :C) + end + subplot(_show ? :show : :end) + end +end + +# ---------------------------------------------------------------------------------------------------------- +""" + montage(D::Vector{<:GMTdataset}; grid=(0,0), panels_size="", margins="0.0", choro=true, colorbar=false, attribs=String[], title="", titles=String[], - divlines=nothing, show=true, kw...) + divlines=false, show=true, kw...) Display a multi-panel choropleth from vector polygon data. Each panel maps one numeric attribute of `D` (obtained via `getattribs`). Panels are laid out in a subplot grid, each @@ -101,9 +335,9 @@ drawn with `choropleth`. - `D`: Vector of `GMTdataset` polygons with numeric attributes stored in `D[k].attrib`. ### Keywords -- `grid`: Tuple `(nrows, ncols)`. Default: approximately square for the number of attributes. -- `panels_size`: Panel size in cm. Default: auto-estimated from a trial plot. -- `margins`: Subplot margins (GMT `-M` syntax). Default: auto-tuned for titles/colorbars. +- `grid`: Tuple `(nrows, ncols)`. Use `(0,0)` for auto. Default: `(0,0)`. +- `panels_size`: Panel size in cm. Use `""` for auto-estimated. Default: `""`. +- `margins`: Subplot margins (GMT `-M` syntax). Default: `"0.0"` (auto-tuned for titles/colorbars). - `choro`: If `true` (default), use choropleth mode (one panel per attribute). - `colorbar`: Colorbar placement — `false` (no bar), `true` (right side), `:bot` (bottom), or a GMT colorbar position string (e.g. `"JBC+o0/5p"`). Default: `true` when `choro=true`. @@ -124,12 +358,25 @@ montage(D, colorbar=:bot, title="Iberia + France") See also: `choropleth`, `subplot`, `getattribs` """ -function montage(D::Vector{<:GMTdataset}; grid=nothing, panels_size=nothing, margins=nothing, choro=true, - colorbar::Union{Bool,Symbol,NamedTuple,String}=false, title::String="", titles=String[], - attribs::Vector{String}=String[], divlines = nothing, show::Bool=true, kw...) +function montage(D::Vector{<:GMTdataset}; kw...) d = KW(kw) + _montage_choro(D, d) +end + +function _montage_choro(D::Vector{<:GMTdataset}, d::Dict) + grid = pop!(d, :grid, (0,0)) + ps_val = pop!(d, :panels_size, "") + margins = string(pop!(d, :margins, "0.0"))::String + choro = pop!(d, :choro, true) + colorbar = pop!(d, :colorbar, false) + title = string(pop!(d, :title, ""))::String + titles = pop!(d, :titles, String[]) + attribs = pop!(d, :attribs, String[])::Vector{String} + divlines = pop!(d, :divlines, false) + _show = pop!(d, :show, true)::Bool + atts = getattribs(D); - deleteat!(atts, atts .== "Feature_ID") # Remove Feature_ID, it's not part of this story + deleteat!(atts, atts .== "Feature_ID") !isempty(attribs) && (atts = intersect(atts, attribs)) n_atts = length(atts) n_atts == 0 && (@warn("No attributes to display"); return nothing) @@ -138,66 +385,58 @@ function montage(D::Vector{<:GMTdataset}; grid=nothing, panels_size=nothing, mar _choro = (n_atts > 0 && choro == 1) (!_choro && length(D) < 2) && error("No reasonable datasets to display") - cbar = (colorbar == false && _choro) ? true : colorbar # Type unstable - nrows, ncols = _montage_grid_size(n_atts, grid) # Calculate grid dimensions + cbar = (colorbar == false && _choro) ? true : colorbar + nrows, ncols = _montage_grid_size(n_atts, grid) - Vd = get(d, :Vd, 0) # Get the Vd option that will be consumed by subplot + Vd = get(d, :Vd, 0) opt_R = parse_R(d, "")[1] - d[:R] = (opt_R !== "") ? opt_R[4:end] : getregion(D)[1:4] # Prepare info so CTRL.limits is set in next call + d[:R] = (opt_R !== "") ? opt_R[4:end] : getregion(D)[1:4] parse_R(d, "", del=false) - opt_J = parse_J(d, "", default="x?", map=false, del=true)[1] # Now that CTRL.limits is set, J=:guess works + opt_J = parse_J(d, "", default="x?", map=false, del=true)[1] - # We need to know if we have to add a "/?" or just a "?" to the opt_J slash = ((isdigit(opt_J[end]) && ~startswith(opt_J, " -JXp")) || (occursin("Cyl_", opt_J) || occursin("Poly", opt_J)) || (startswith(opt_J, " -JU") && length(opt_J) > 4)) ? "/" : "" opt_B = parse_B(d, "", "")[1] - (opt_B !== "") && (opt_B = replace(opt_B, "-B" => "")) # So that a multi-word -B can be rebuilt later - (opt_B !== "") ? (d[:B] = opt_B) : (d[:B] = "+n"; d[:D] = true) - (d[:B] == "+n") && (cbar == :bot) && (cbar = "JBC+o0/5p") # Move the colorbar up a bit. It was too far from south axis - (d[:B] == "+n") && (cbar == true) && (cbar = "JRM+o5p/0") # Move the colorbar left a bit. It was too far from right axis + (opt_B !== "") && (opt_B = replace(opt_B, "-B" => "")) + _montage_setup_frame!(d, "", opt_B) + (d[:B] == "+n") && (cbar == :bot) && (cbar = "JBC+o0/5p") + (d[:B] == "+n") && (cbar == true) && (cbar = "JRM+o5p/0") d[:grid] = (nrows, ncols) - dy = !isempty(titles) ? 0.5 : 0.0 - (cbar !== false) && (dy += 0.6) - dx = (d[:B] != "+n") && (colorbar == true || colorbar == :right || (isa(colorbar, String) && contains(cbar, "RM"))) ? 0.90 : 0.38 - (margins === nothing && (dx != 0 || dy != 0)) && (d[:M] = "0.38c/$(dx)c/$(dy)c/0") # No margins set, titles neead tweaks. - (margins !== nothing) && (d[:M] = arg2str(margins)) # Margins option was set, use them - d[:par] = (MAP_TITLE_OFFSET="0p", FONT_TITLE="auto,Helvetica,black") + _montage_adjust_margins!(d, titles, colorbar, cbar, margins) (title !== "") && (d[:title] = title) ps = max(3, min(8, 18 / max(nrows, ncols))) - (divlines !== nothing) && (d[:divlines] = divlines) - ((dvl = add_opt_pen(d, [:divlines])) !== "") && (dvl = "+w" * dvl) # Add divlines between pannels - if (panels_size === nothing) - width, height = estimate_plot_size(D, colorbar == false ? false : colorbar, isempty(titles) ? "" : "Bla", nothing, d[:B] != "+n", kw...) + dvl = _montage_divlines!(d, divlines) + if ps_val == "" || ps_val === nothing + width, height = estimate_plot_size(D, colorbar == false ? false : colorbar, isempty(titles) ? "" : "Bla", nothing, d[:B] != "+n") d[:Fs] = string(ps,"/", round(height/width*ps, digits=2), dvl) else d[:Fs] = string(ps,dvl) end - subplot("", false, d) # Create subplot (since we already have the Dict pass it directly) + subplot("", false, d) - d = CTRL.pocket_d[1] # Fetch options not consumed by subplot. + d = CTRL.pocket_d[1] d[:J] = (opt_J == "x?") ? (isgeog(D) ? "q?" : "x?") : opt_J[4:end] * slash * "?" for k = 1:n_atts panel_title = (!isempty(titles) && k <= length(titles)) ? titles[k] : nothing choropleth(D, atts[k]; panel=k, title=panel_title, colorbar=(colorbar == false ? false : cbar), Vd=Vd, d...) end - subplot(show ? :show : :end) # End subplot + subplot(_show ? :show : :end) return nothing end # ---------------------------------------------------------------------------------------------------------- -function _montage_grid_size(n, size) - # Calculate grid dimension - if size === nothing || all(x -> x === nothing || x == 0 || (isa(x, AbstractFloat) && isnan(x)), size) +function _montage_grid_size(n::Int, grid)::Tuple{Int,Int} + if grid == (0,0) || grid === nothing ncols = ceil(Int, sqrt(n)) nrows = ceil(Int, n / ncols) else - nrows, ncols = size + nrows, ncols = grid (nrows == 0 || (isa(nrows, AbstractFloat) && isnan(nrows))) && (nrows = ceil(Int, n / ncols)) (ncols == 0 || (isa(ncols, AbstractFloat) && isnan(ncols))) && (ncols = ceil(Int, n / nrows)) end @@ -211,7 +450,6 @@ function estimate_plot_size(D, cbar, title, proj, has_B, kw...) (cbar != false) && makecpt(C=:jet) # Just a tinny CPT to get to the dimensions has_B && (cbar == :bot) && (cbar = "JBC+o0/25p") # Add space for baddly accounted colorbar position plot(D; dpi=50, title=title, colorbar=cbar, J=proj, savefig=fname, kw...) - #dims = Int.(gmt("grdinfo -C " * fname)[9:10]) dims = getsize(fname) return dims end