module fortplot_subplot_layout use, intrinsic :: iso_fortran_env, only: wp => real64 use fortplot_plot_data, only: subplot_data_t use fortplot_figure_data_ranges, only: calculate_figure_data_ranges use fortplot_subplot_colorbar, only: panel_colorbar_tick_labels use fortplot_axes, only: compute_scale_ticks, format_tick_label, MAX_TICKS use fortplot_tick_calculation, only: determine_decimals_from_ticks, & format_tick_value_consistent use fortplot_constants, only: TICK_MARK_LENGTH, TITLE_PAD_PT, & AXIS_LABEL_PAD_PT, XLABEL_VERTICAL_OFFSET, & X_TICK_LABEL_PAD, & Y_TICK_LABEL_RIGHT_PAD use fortplot_text_layout, only: calculate_text_width, calculate_text_height, & calculate_text_height_with_size_internal, & TITLE_FONT_SIZE use fortplot_latex_parser, only: process_latex_in_text use fortplot_text_helpers, only: prepare_mathtext_if_needed use fortplot_unicode, only: escape_unicode_for_raster use fortplot_raster, only: raster_context use fortplot_raster_core, only: pt2px, raster_image_t use fortplot_raster_labels, only: xlabel_bottom_padding use fortplot_pdf, only: pdf_context use fortplot_pdf_text, only: estimate_pdf_text_width use fortplot_pdf_core, only: PDF_TICK_LABEL_SIZE, PDF_LABEL_SIZE, PDF_TITLE_SIZE implicit none private public :: compute_tight_subplot_margins ! Minimum fraction of the figure height the panel rows should fill, so short ! figures (e.g. a 1xN strip) do not waste vertical space the way a purely ! decoration-driven layout does. Matches matplotlib's default axes band ! (figure.subplot.top - figure.subplot.bottom). real(wp), parameter :: MIN_AXES_FILL_FRAC = 0.77_wp ! Gap between a colorbar's bar and its tick labels (tick mark + pad) integer, parameter :: CBAR_TICK_GAP_PX = 8 contains subroutine compute_tight_subplot_margins(backend, subplots_array, nr, nc, & xscale, yscale, symlog_threshold, left_f, & right_f, bottom_f, top_f, & ok, suptitle_height_frac) !! Compute per-cell subplot margins in fractional figure coordinates. !! Margins, inter-column gaps, and inter-row gaps are driven by each !! panel's decoration sizes (tick labels, axis labels, titles) so every !! panel's y-axis label sits to the left of its plot box and the top !! row's x-axis label clears the title of the row below. Short figures !! additionally reclaim excess margin so the panels fill at least !! MIN_AXES_FILL_FRAC of the figure height, matching matplotlib's !! default vertical fill. class(*), intent(in) :: backend type(subplot_data_t), intent(in) :: subplots_array(:, :) integer, intent(in) :: nr, nc character(len=*), intent(in) :: xscale, yscale real(wp), intent(in) :: symlog_threshold real(wp), allocatable, intent(out) :: left_f(:, :), right_f(:, :) real(wp), allocatable, intent(out) :: bottom_f(:, :), top_f(:, :) logical, intent(out) :: ok real(wp), intent(in), optional :: suptitle_height_frac real(wp) :: sup_frac real(wp), allocatable :: dec_left(:, :), dec_right(:, :) real(wp), allocatable :: dec_bottom(:, :), dec_top(:, :) integer :: i, j real(wp) :: fig_w, fig_h ok = .false. sup_frac = 0.0_wp if (present(suptitle_height_frac)) sup_frac = max(0.0_wp, suptitle_height_frac) if (nr <= 0 .or. nc <= 0) return if (size(subplots_array, 1) /= nr) return if (size(subplots_array, 2) /= nc) return allocate (left_f(nr, nc), right_f(nr, nc)) allocate (bottom_f(nr, nc), top_f(nr, nc)) allocate (dec_left(nr, nc), dec_right(nr, nc)) allocate (dec_bottom(nr, nc), dec_top(nr, nc)) select type (bk => backend) class is (raster_context) fig_w = real(max(1, bk%width), wp) fig_h = real(max(1, bk%height), wp) do i = 1, nr do j = 1, nc call estimate_subplot_decorations_raster(subplots_array(i, j), & subplots_array(i, j)%xscale, & subplots_array(i, j)%yscale, & subplots_array(i, j)% & symlog_threshold, & bk%raster, & dec_left(i, j), & dec_right(i, j), & dec_bottom(i, j), & dec_top(i, j)) end do end do class is (pdf_context) fig_w = real(max(1, bk%width), wp) fig_h = real(max(1, bk%height), wp) do i = 1, nr do j = 1, nc call estimate_subplot_decorations_pdf(subplots_array(i, j), & subplots_array(i, j)%xscale, & subplots_array(i, j)%yscale, & subplots_array(i, j)% & symlog_threshold, & dec_left(i, j), & dec_right(i, j), & dec_bottom(i, j), & dec_top(i, j)) end do end do class default return end select call solve_tight_grid(fig_w, fig_h, dec_left, dec_right, dec_bottom, & dec_top, left_f, right_f, bottom_f, top_f, ok, sup_frac) end subroutine compute_tight_subplot_margins subroutine solve_tight_grid(fig_w, fig_h, dec_left, dec_right, dec_bottom, & dec_top, left_f, right_f, bottom_f, top_f, ok, & suptitle_height_frac) real(wp), intent(in) :: fig_w, fig_h real(wp), contiguous, intent(in) :: dec_left(:, :), dec_right(:, :) real(wp), contiguous, intent(in) :: dec_bottom(:, :), dec_top(:, :) real(wp), intent(out) :: left_f(:, :), right_f(:, :) real(wp), intent(out) :: bottom_f(:, :), top_f(:, :) logical, intent(out) :: ok real(wp), intent(in) :: suptitle_height_frac integer :: nr, nc, i, j real(wp), allocatable :: gap_x(:), gap_y(:) real(wp) :: margin_l, margin_r, margin_b, margin_t real(wp) :: axes_w, axes_h real(wp) :: avail_w, avail_h real(wp) :: pad real(wp) :: x0, x1, y0, y1 nr = size(dec_left, 1) nc = size(dec_left, 2) ok = .false. pad = 6.0_wp allocate (gap_x(max(0, nc - 1))) allocate (gap_y(max(0, nr - 1))) call compute_margins_and_gaps(dec_left, dec_right, dec_bottom, dec_top, & pad, fig_h, suptitle_height_frac, & margin_l, margin_r, margin_b, margin_t, & gap_x, gap_y) call reclaim_vertical_margin(fig_h, pad, suptitle_height_frac, & sum(gap_y), nr, margin_t, margin_b) avail_w = fig_w - margin_l - margin_r if (nc > 1) avail_w = avail_w - sum(gap_x) avail_h = fig_h - margin_t - margin_b if (nr > 1) avail_h = avail_h - sum(gap_y) if (avail_w <= real(nc, wp) .or. avail_h <= real(nr, wp)) return axes_w = avail_w/real(nc, wp) axes_h = avail_h/real(nr, wp) do i = 1, nr y1 = fig_h - margin_t if (i > 1) then y1 = y1 - real(i - 1, wp)*axes_h - sum(gap_y(1:i - 1)) end if y0 = y1 - axes_h do j = 1, nc x0 = margin_l if (j > 1) then x0 = x0 + real(j - 1, wp)*axes_w + sum(gap_x(1:j - 1)) end if x1 = x0 + axes_w left_f(i, j) = x0/fig_w right_f(i, j) = x1/fig_w bottom_f(i, j) = y0/fig_h top_f(i, j) = y1/fig_h end do end do ok = .true. end subroutine solve_tight_grid subroutine compute_margins_and_gaps(dec_left, dec_right, dec_bottom, dec_top, & pad, fig_h, suptitle_height_frac, & margin_l, margin_r, margin_b, margin_t, & gap_x, gap_y) !! Outer margins and inter-panel gaps from per-cell decoration sizes. !! Margins reserve room for the outermost decorations plus a uniform pad !! (and a suptitle band on top); each gap reserves room for the adjacent !! decorations facing it, so y-labels stay left of every panel and the !! top row's x-label clears the title of the row below. real(wp), contiguous, intent(in) :: dec_left(:, :), dec_right(:, :) real(wp), contiguous, intent(in) :: dec_bottom(:, :), dec_top(:, :) real(wp), intent(in) :: pad, fig_h, suptitle_height_frac real(wp), intent(out) :: margin_l, margin_r, margin_b, margin_t real(wp), intent(out) :: gap_x(:), gap_y(:) integer :: nr, nc, i, j nr = size(dec_left, 1) nc = size(dec_left, 2) margin_l = pad margin_r = pad margin_b = pad margin_t = pad do i = 1, nr margin_l = max(margin_l, dec_left(i, 1) + pad) margin_r = max(margin_r, dec_right(i, nc) + pad) end do do j = 1, nc margin_b = max(margin_b, dec_bottom(nr, j) + pad) margin_t = max(margin_t, dec_top(1, j) + pad) end do if (suptitle_height_frac > 0.0_wp) then margin_t = margin_t + suptitle_height_frac*fig_h + 2.0_wp*pad end if do j = 1, nc - 1 gap_x(j) = pad do i = 1, nr gap_x(j) = max(gap_x(j), dec_right(i, j) + dec_left(i, j + 1) + pad) end do end do do i = 1, nr - 1 gap_y(i) = pad do j = 1, nc gap_y(i) = max(gap_y(i), dec_bottom(i, j) + dec_top(i + 1, j) + pad) end do end do end subroutine compute_margins_and_gaps subroutine reclaim_vertical_margin(fig_h, pad, suptitle_height_frac, & gap_y_sum, nr, margin_t, margin_b) !! Shrink excess outer vertical margin so short figures fill the height !! like matplotlib, without clipping decorations off the figure. Only the !! outer-margin padding and the suptitle clearance padding are reclaimed; !! the decoration sizes and inter-row gaps stay as overlap guards. real(wp), intent(in) :: fig_h, pad, suptitle_height_frac, gap_y_sum integer, intent(in) :: nr real(wp), intent(inout) :: margin_t, margin_b real(wp) :: target_h, panels_h, slack, factor target_h = MIN_AXES_FILL_FRAC*fig_h if (nr > 1) target_h = target_h - gap_y_sum panels_h = fig_h - margin_t - margin_b if (panels_h >= target_h) return slack = 2.0_wp*pad if (suptitle_height_frac > 0.0_wp) slack = slack + 2.0_wp*pad if (slack <= 0.0_wp) return factor = min(1.0_wp, (target_h - panels_h)/slack) margin_b = margin_b - factor*pad margin_t = margin_t - factor*pad if (suptitle_height_frac > 0.0_wp) margin_t = margin_t - factor*2.0_wp*pad end subroutine reclaim_vertical_margin subroutine estimate_subplot_decorations_raster(subplot, xscale, yscale, & symlog_threshold, raster, dec_left, & dec_right, dec_bottom, dec_top) type(subplot_data_t), intent(in) :: subplot character(len=*), intent(in) :: xscale, yscale real(wp), intent(in) :: symlog_threshold type(raster_image_t), intent(in) :: raster real(wp), intent(out) :: dec_left, dec_right, dec_bottom, dec_top integer :: n_x, n_y, i character(len=50) :: x_labels(MAX_TICKS), y_labels(MAX_TICKS) integer :: max_y_w, max_x_h real(wp) :: last_x_half_w, top_y_half_h integer :: ylabel_h, title_h character(len=:), allocatable :: title, xlabel, ylabel real(wp) :: vxmin, vxmax, vymin, vymax call panel_view_limits(subplot, xscale, yscale, symlog_threshold, & vxmin, vxmax, vymin, vymax) max_y_w = 0 max_x_h = 0 last_x_half_w = 0.0_wp top_y_half_h = 0.0_wp call panel_tick_labels(subplot%custom_xtick_positions, & subplot%custom_xtick_labels, xscale, vxmin, vxmax, & symlog_threshold, x_labels, n_x) call panel_tick_labels(subplot%custom_ytick_positions, & subplot%custom_ytick_labels, yscale, vymin, vymax, & symlog_threshold, y_labels, n_y) if (n_x > 0) then do i = 1, n_x max_x_h = max(max_x_h, measure_raster_height(trim(x_labels(i)))) end do last_x_half_w = 0.5_wp*real(measure_raster_width(trim(x_labels(n_x))), wp) end if if (n_y > 0) then do i = 1, n_y max_y_w = max(max_y_w, measure_raster_width(trim(y_labels(i)))) end do top_y_half_h = 0.5_wp*real(measure_raster_height(trim(y_labels(n_y))), wp) end if title = '' xlabel = '' ylabel = '' if (allocated(subplot%title)) title = subplot%title if (allocated(subplot%xlabel)) xlabel = subplot%xlabel if (allocated(subplot%ylabel)) ylabel = subplot%ylabel ylabel_h = 0 if (len_trim(ylabel) > 0) ylabel_h = measure_raster_height(ylabel) title_h = 0 if (len_trim(title) > 0) then title_h = & calculate_text_height_with_size_internal(real(TITLE_FONT_SIZE, wp)) end if dec_left = real(Y_TICK_LABEL_RIGHT_PAD + max_y_w, wp) dec_left = max(dec_left, real(TICK_MARK_LENGTH, wp)) if (len_trim(ylabel) > 0) then ! Reserve the y-label band to match raster_render_ylabel: tick mark, ! tick-label right pad, tick-label width, the matplotlib axes.labelpad ! gap, and the rotated y-label thickness. dec_left = real(TICK_MARK_LENGTH + Y_TICK_LABEL_RIGHT_PAD + max_y_w + & ylabel_h, wp) + pt2px(AXIS_LABEL_PAD_PT, raster%dpi) end if dec_right = last_x_half_w ! Tick labels sit X_TICK_LABEL_PAD below the spine; the x-axis label is ! placed an additional pt2px(AXIS_LABEL_PAD_PT) below the tick-label ! bottom edge (matplotlib axes.labelpad), matching the raster renderer. dec_bottom = real(X_TICK_LABEL_PAD + max_x_h, wp) if (len_trim(xlabel) > 0) then dec_bottom = max(dec_bottom, xlabel_bottom_padding(raster, max_x_h)) end if call add_colorbar_space_raster(subplot, raster, dec_right, dec_bottom) dec_top = 0.0_wp if (len_trim(title) > 0) then ! Title baseline sits pt2px(TITLE_PAD_PT) above the top spine ! (matplotlib axes.titlepad); the glyphs rise title_h above it. dec_top = pt2px(TITLE_PAD_PT, raster%dpi) + real(title_h, wp) end if dec_top = max(dec_top, top_y_half_h) end subroutine estimate_subplot_decorations_raster subroutine estimate_subplot_decorations_pdf(subplot, xscale, yscale, & symlog_threshold, dec_left, dec_right, & dec_bottom, dec_top) type(subplot_data_t), intent(in) :: subplot character(len=*), intent(in) :: xscale, yscale real(wp), intent(in) :: symlog_threshold real(wp), intent(out) :: dec_left, dec_right, dec_bottom, dec_top integer :: n_x, n_y, i character(len=50) :: x_labels(MAX_TICKS), y_labels(MAX_TICKS) real(wp) :: max_y_w, max_x_h, last_x_half_w real(wp) :: xlabel_h, title_h character(len=:), allocatable :: title, xlabel, ylabel real(wp), parameter :: X_TICK_GAP = 15.0_wp real(wp), parameter :: Y_TICK_GAP = 1.0_wp real(wp), parameter :: TITLE_GAP = 6.0_wp real(wp), parameter :: YLABEL_PAD = 1.0_wp real(wp), parameter :: LABEL_THICKNESS = 1.2_wp*PDF_LABEL_SIZE real(wp) :: vxmin, vxmax, vymin, vymax call panel_view_limits(subplot, xscale, yscale, symlog_threshold, & vxmin, vxmax, vymin, vymax) max_y_w = 0.0_wp max_x_h = 0.0_wp last_x_half_w = 0.0_wp call panel_tick_labels(subplot%custom_xtick_positions, & subplot%custom_xtick_labels, xscale, vxmin, vxmax, & symlog_threshold, x_labels, n_x) call panel_tick_labels(subplot%custom_ytick_positions, & subplot%custom_ytick_labels, yscale, vymin, vymax, & symlog_threshold, y_labels, n_y) if (n_x > 0) then do i = 1, n_x max_x_h = max(max_x_h, PDF_TICK_LABEL_SIZE*1.2_wp) end do last_x_half_w = 0.5_wp*estimate_pdf_text_width(trim(x_labels(n_x)), & PDF_TICK_LABEL_SIZE) end if if (n_y > 0) then do i = 1, n_y max_y_w = max(max_y_w, estimate_pdf_text_width(trim(y_labels(i)), & PDF_TICK_LABEL_SIZE)) end do end if title = '' xlabel = '' ylabel = '' if (allocated(subplot%title)) title = subplot%title if (allocated(subplot%xlabel)) xlabel = subplot%xlabel if (allocated(subplot%ylabel)) ylabel = subplot%ylabel xlabel_h = 0.0_wp if (len_trim(xlabel) > 0) xlabel_h = PDF_LABEL_SIZE*1.2_wp title_h = 0.0_wp if (len_trim(title) > 0) title_h = PDF_TITLE_SIZE*1.2_wp dec_left = Y_TICK_GAP + max_y_w if (len_trim(ylabel) > 0) then dec_left = dec_left + YLABEL_PAD + LABEL_THICKNESS end if dec_right = last_x_half_w dec_bottom = X_TICK_GAP + max_x_h if (len_trim(xlabel) > 0) then dec_bottom = max(dec_bottom, real(XLABEL_VERTICAL_OFFSET, wp) + xlabel_h) end if call add_colorbar_space_pdf(subplot, dec_right, dec_bottom) dec_top = 0.0_wp if (len_trim(title) > 0) then dec_top = TITLE_GAP + title_h end if ! The top y tick label is centred on the upper spine. if (n_y > 0) dec_top = max(dec_top, 0.6_wp*PDF_TICK_LABEL_SIZE) end subroutine estimate_subplot_decorations_pdf integer function measure_raster_width(text) result(w) character(len=*), intent(in) :: text character(len=500) :: processed integer :: plen, mlen character(len=600) :: math_ready character(len=600) :: escaped if (len_trim(text) == 0) then w = 0 return end if call process_latex_in_text(trim(text), processed, plen) call prepare_mathtext_if_needed(processed(1:plen), math_ready, mlen) call escape_unicode_for_raster(math_ready(1:mlen), escaped) w = calculate_text_width(trim(escaped)) end function measure_raster_width integer function measure_raster_height(text) result(h) character(len=*), intent(in) :: text character(len=500) :: processed integer :: plen, mlen character(len=600) :: math_ready character(len=600) :: escaped if (len_trim(text) == 0) then h = 0 return end if call process_latex_in_text(trim(text), processed, plen) call prepare_mathtext_if_needed(processed(1:plen), math_ready, mlen) call escape_unicode_for_raster(math_ready(1:mlen), escaped) h = calculate_text_height(trim(escaped)) if (h <= 0) h = 12 end function measure_raster_height subroutine panel_tick_labels(positions, custom_labels, scale, lo, hi, & threshold, labels, n) real(wp), intent(in), optional :: positions(:) character(len=*), intent(in), optional :: custom_labels(:) character(len=*), intent(in) :: scale real(wp), intent(in) :: lo, hi, threshold character(len=*), intent(out) :: labels(:) integer, intent(out) :: n real(wp) :: ticks(MAX_TICKS) integer :: i, decimals call compute_scale_ticks(scale, lo, hi, threshold, ticks, n) if (present(positions)) then n = min(size(labels), size(positions)) ticks(:n) = positions(:n) end if decimals = 0 if (trim(scale) == 'linear' .and. n >= 2) & decimals = determine_decimals_from_ticks(ticks, n) do i = 1, n if (trim(scale) == 'linear') then labels(i) = format_tick_value_consistent(ticks(i), decimals) else labels(i) = format_tick_label(ticks(i), scale, data_min=lo, data_max=hi) end if if (present(custom_labels)) then if (i <= size(custom_labels)) labels(i) = custom_labels(i) end if end do end subroutine panel_tick_labels subroutine panel_view_limits(subplot, xscale, yscale, symlog_threshold, & vxmin, vxmax, vymin, vymax) !! View limits the panel renderer will use: explicit xlim/ylim when set, !! otherwise the autoscaled extent of every artist in the panel (meshes, !! fills and markers included), so tick labels are estimated for the !! axes that are actually drawn. type(subplot_data_t), intent(in) :: subplot character(len=*), intent(in) :: xscale, yscale real(wp), intent(in) :: symlog_threshold real(wp), intent(out) :: vxmin, vxmax, vymin, vymax real(wp) :: txmin, txmax, tymin, tymax vxmin = 0.0_wp; vxmax = 1.0_wp; vymin = 0.0_wp; vymax = 1.0_wp if (subplot%xlim_set) then vxmin = subplot%x_min; vxmax = subplot%x_max end if if (subplot%ylim_set) then vymin = subplot%y_min; vymax = subplot%y_max end if if (.not. allocated(subplot%plots)) return call calculate_figure_data_ranges(subplot%plots, subplot%plot_count, & subplot%xlim_set, subplot%ylim_set, & vxmin, vxmax, vymin, vymax, & txmin, txmax, tymin, tymax, & xscale, yscale, symlog_threshold) end subroutine panel_view_limits subroutine add_colorbar_space_raster(subplot, raster, dec_right, dec_bottom) !! Room for a panel colorbar's tick labels and label (pixels). The bar !! sits inside the panel box; its labels extend beyond it. type(subplot_data_t), intent(in) :: subplot type(raster_image_t), intent(in) :: raster real(wp), intent(inout) :: dec_right, dec_bottom character(len=50) :: labels(40) character(len=:), allocatable :: label character(len=10) :: loc logical :: active integer :: n, i, tick_w, tick_h real(wp) :: fs, extent call panel_colorbar_tick_labels(subplot, active, loc, labels, n, label, fs) if (.not. active) return tick_w = 0 tick_h = 0 do i = 1, n tick_w = max(tick_w, measure_raster_width(trim(labels(i)))) tick_h = max(tick_h, measure_raster_height(trim(labels(i)))) end do if (trim(loc) == 'right') then extent = real(CBAR_TICK_GAP_PX + tick_w, wp) ! The rotated label is centred 4 font heights right of the bar. if (len_trim(label) > 0) extent = max(extent, & 4.6_wp*pt2px(fs, raster%dpi) + 2.0_wp) dec_right = max(dec_right, extent) else if (trim(loc) == 'bottom') then extent = real(CBAR_TICK_GAP_PX + tick_h, wp) if (len_trim(label) > 0) extent = extent + & real(measure_raster_height(label), wp) dec_bottom = dec_bottom + extent end if end subroutine add_colorbar_space_raster subroutine add_colorbar_space_pdf(subplot, dec_right, dec_bottom) !! PDF counterpart of add_colorbar_space_raster (points). type(subplot_data_t), intent(in) :: subplot real(wp), intent(inout) :: dec_right, dec_bottom character(len=50) :: labels(40) character(len=:), allocatable :: label character(len=10) :: loc logical :: active integer :: n, i real(wp) :: fs, tick_w, extent call panel_colorbar_tick_labels(subplot, active, loc, labels, n, label, fs) if (.not. active) return tick_w = 0.0_wp do i = 1, n tick_w = max(tick_w, estimate_pdf_text_width(trim(labels(i)), & PDF_TICK_LABEL_SIZE)) end do if (trim(loc) == 'right') then extent = real(CBAR_TICK_GAP_PX, wp) + tick_w if (len_trim(label) > 0) extent = max(extent, 4.6_wp*fs + 2.0_wp) dec_right = max(dec_right, extent) else if (trim(loc) == 'bottom') then extent = real(CBAR_TICK_GAP_PX, wp) + 1.2_wp*PDF_TICK_LABEL_SIZE if (len_trim(label) > 0) extent = extent + 1.2_wp*fs dec_bottom = dec_bottom + extent end if end subroutine add_colorbar_space_pdf end module fortplot_subplot_layout