fortplot_subplot_rendering.f90 Source File


Source Code

module fortplot_subplot_rendering
    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_plot_data, only: subplot_data_t
    use fortplot_figure_initialization, only: figure_state_t
    use fortplot_figure_rendering_pipeline, only: calculate_figure_data_ranges, &
                                                  setup_coordinate_system, &
                                                  render_figure_axes, &
                                                  render_all_plots, &
                                                  render_figure_axes_labels_only
    use fortplot_figure_data_ranges, only: determine_sticky_edges
    use fortplot_margins, only: calculate_plot_area
    use fortplot_text_layout, only: TITLE_FONT_SIZE_PT, calculate_text_height_with_size
    use fortplot_pdf_coordinate, only: calculate_pdf_plot_area
    use fortplot_subplot_layout, only: compute_tight_subplot_margins
    use fortplot_raster, only: raster_context
    use fortplot_pdf, only: pdf_context
    use fortplot_pdf_core, only: PDF_TITLE_SIZE
    use fortplot_ascii, only: ascii_context
    use fortplot_raster_labels, only: render_title_centered_with_size
    use fortplot_pdf_text, only: estimate_pdf_text_width
    use fortplot_ascii_mathtext, only: sanitize_ascii_text
    use fortplot_legend, only: legend_render
    use fortplot_context, only: plot_context
    implicit none

    private
    public :: render_subplots

contains

    subroutine render_subplots(state, subplots_array, subplot_rows, subplot_cols)
        type(figure_state_t), intent(inout) :: state
        type(subplot_data_t), intent(in) :: subplots_array(:, :)
        integer, intent(in) :: subplot_rows, subplot_cols

        integer :: nr, nc, i, j
        real(wp), allocatable :: left_f(:, :), right_f(:, :)
        real(wp), allocatable :: bottom_f(:, :), top_f(:, :)
        logical :: have_tight
        real(wp) :: base_left, base_right, base_bottom, base_top
        real(wp) :: wspace, hspace
        real(wp) :: total_w, total_h
        real(wp) :: ax_w, ax_h
        real(wp) :: gap_w, gap_h
        character(len=64) :: x_date_format, y_date_format
        real(wp) :: suptitle_height_frac
        real(wp) :: fig_w, fig_h

        nr = subplot_rows
        nc = subplot_cols

        x_date_format = ''
        y_date_format = ''
        if (allocated(state%xaxis_date_format)) x_date_format = state%xaxis_date_format
        if (allocated(state%yaxis_date_format)) y_date_format = state%yaxis_date_format

        ! Calculate suptitle height in fractional figure coordinates for layout adjustment
        suptitle_height_frac = compute_suptitle_height_frac(state, fig_w, fig_h)

        have_tight = .false.
        call compute_tight_subplot_margins(state%backend, subplots_array, nr, nc, &
                                           state%xscale, state%yscale, &
                                           state%symlog_threshold, &
                                           left_f, right_f, &
                                           bottom_f, top_f, have_tight, &
                                           suptitle_height_frac=suptitle_height_frac)

        if (.not. have_tight) then
            base_left = 0.125_wp
            base_right = 0.90_wp
            base_bottom = 0.11_wp
            base_top = 0.88_wp
            wspace = 0.20_wp
            hspace = 0.20_wp

            ! Reserve space for suptitle above subplot area. Use a nested test
            ! rather than a compound .and.: Fortran does not guarantee
            ! short-circuit evaluation, so len_trim must not see an unallocated
            ! suptitle (this path runs for the ASCII backend, where suptitle is
            ! often unallocated; issue #2019).
            if (allocated(state%suptitle)) then
                if (len_trim(state%suptitle) > 0) then
                    base_top = base_top - suptitle_height_frac
                end if
            end if

            total_w = base_right - base_left
            total_h = base_top - base_bottom

            ax_w = total_w/ &
                   (real(nc, wp) + wspace*real(max(0, nc - 1), wp))
            gap_w = wspace*ax_w

            ax_h = total_h/ &
                   (real(nr, wp) + hspace*real(max(0, nr - 1), wp))
            gap_h = hspace*ax_h
        end if

        do i = 1, nr
            do j = 1, nc
                call render_subplot_cell( &
                    state, subplots_array(i, j), i, j, have_tight, &
                    left_f, right_f, bottom_f, top_f, &
                    base_left, base_bottom, base_top, &
                    ax_w, ax_h, gap_w, gap_h, &
                    x_date_format, y_date_format)
            end do
        end do

        select type (bk => state%backend)
        class is (ascii_context)
            call render_ascii_subplot_titles(state%backend, subplots_array, nr, nc, &
                                             have_tight, left_f, right_f, bottom_f, &
                                             top_f, base_left, base_bottom, base_top, &
                                             ax_w, ax_h, gap_w, gap_h)
        class default
        end select

        call render_suptitle(state, suptitle_height_frac)
        if (state%show_legend .and. state%legend_data%num_entries > 0) then
            call restore_figure_legend_frame(state%backend, state%margin_left, &
                                             state%margin_right, state%margin_bottom, &
                                             state%margin_top)
            call apply_subplot_legend_bounds(state%backend, subplots_array, nr, nc)
            call legend_render(state%legend_data, state%backend)
        end if
    end subroutine render_subplots

    subroutine render_subplot_cell(state, sp, i, j, have_tight, &
                                    left_f, right_f, bottom_f, top_f, &
                                    base_left, base_bottom, base_top, &
                                    ax_w, ax_h, gap_w, gap_h, &
                                    x_date_format, y_date_format)
        !! Render a single subplot cell: margins, axes, plots, labels
        type(figure_state_t), intent(inout) :: state
        type(subplot_data_t), intent(in) :: sp
        integer, intent(in) :: i, j
        logical, intent(in) :: have_tight
        real(wp), intent(in), optional :: left_f(:,:), right_f(:,:)
        real(wp), intent(in), optional :: bottom_f(:,:), top_f(:,:)
        real(wp), intent(in) :: base_left, base_bottom, base_top
        real(wp), intent(in) :: ax_w, ax_h, gap_w, gap_h
        character(len=*), intent(in) :: x_date_format, y_date_format

        real(wp) :: subplot_left, subplot_right
        real(wp) :: subplot_bottom, subplot_top
        real(wp) :: lxmin, lxmax, lymin, lymax
        real(wp) :: lxmin_t, lxmax_t, lymin_t, lymax_t
        character(len=:), allocatable :: axis_title
        logical :: sx_min, sx_max, sy_min, sy_max

        ! Set margins
        if (have_tight) then
            call set_subplot_margins(state%backend, left_f(i, j), &
                                      right_f(i, j), bottom_f(i, j), &
                                      top_f(i, j))
        else
            subplot_left = base_left + real(j - 1, wp)*(ax_w + gap_w)
            subplot_right = subplot_left + ax_w
            subplot_top = base_top - real(i - 1, wp)*(ax_h + gap_h)
            subplot_bottom = subplot_top - ax_h
            call set_subplot_margins(state%backend, subplot_left, &
                                      subplot_right, subplot_bottom, &
                                      subplot_top)
        end if

        call calculate_figure_data_ranges(sp%plots, sp%plot_count, &
                                          sp%xlim_set, sp%ylim_set, &
                                          lxmin, lxmax, lymin, lymax, &
                                          lxmin_t, lxmax_t, lymin_t, lymax_t, &
                                          state%xscale, state%yscale, &
                                          state%symlog_threshold, &
                                          state%symlog_base, state%symlog_linscale)

        call determine_sticky_edges(sp%plots, sp%plot_count, &
                                        sticky_x_min=sx_min, sticky_x_max=sx_max, &
                                        sticky_y_min=sy_min, sticky_y_max=sy_max)
        call setup_coordinate_system(state%backend, lxmin_t, lxmax_t, &
                                     lymin_t, lymax_t, &
                                     sticky_x_min=sx_min, sticky_x_max=sx_max, &
                                     sticky_y_min=sy_min, sticky_y_max=sy_max)

        axis_title = sp%title
        select type (bk => state%backend)
        class is (ascii_context)
            axis_title = ''
        class default
        end select

        call render_figure_axes(state%backend, state%xscale, state%yscale, &
                                state%symlog_threshold, lxmin, lxmax, &
                                lymin, lymax, axis_title, sp%xlabel, sp%ylabel, &
                                sp%plots, sp%plot_count, &
                                has_twinx=.false., has_twiny=.false., &
                                state=state)

        if (sp%plot_count > 0) then
            call render_all_plots(state%backend, sp%plots, sp%plot_count, &
                                  lxmin_t, lxmax_t, lymin_t, lymax_t, &
                                  state%xscale, state%yscale, &
                                  state%symlog_threshold, state%width, &
                                  state%height, &
                                  state%margin_left, state%margin_right, &
                                  state%margin_bottom, state%margin_top)
        end if

        call render_figure_axes_labels_only(state%backend, state%xscale, &
                                            state%yscale, state%symlog_threshold, &
                                            lxmin, lxmax, lymin, lymax, &
                                            sp%title, sp%xlabel, sp%ylabel, &
                                            sp%plots, sp%plot_count, &
                                            has_twinx=.false., has_twiny=.false., &
                                            x_date_format=trim(x_date_format), &
                                            y_date_format=trim(y_date_format))

    end subroutine render_subplot_cell

    subroutine render_ascii_subplot_titles(backend, subplots_array, nr, nc, have_tight, &
                                           left_f, right_f, bottom_f, top_f, &
                                           base_left, base_bottom, base_top, ax_w, &
                                           ax_h, gap_w, gap_h)
        class(plot_context), intent(inout) :: backend
        type(subplot_data_t), intent(in) :: subplots_array(:, :)
        integer, intent(in) :: nr, nc
        logical, intent(in) :: have_tight
        real(wp), intent(in), optional :: left_f(:,:), right_f(:,:)
        real(wp), intent(in), optional :: bottom_f(:,:), top_f(:,:)
        real(wp), intent(in) :: base_left, base_bottom, base_top
        real(wp), intent(in) :: ax_w, ax_h, gap_w, gap_h

        integer :: i, j

        do i = 1, nr
            do j = 1, nc
                if (have_tight) then
                    call set_subplot_margins(backend, left_f(i, j), &
                                             right_f(i, j), bottom_f(i, j), &
                                             top_f(i, j))
                else
                    call set_subplot_margins(backend, &
                                             base_left + real(j - 1, wp)*(ax_w + gap_w), &
                                             base_left + real(j - 1, wp)*(ax_w + gap_w) + ax_w, &
                                             base_top - real(i - 1, wp)*(ax_h + gap_h) - ax_h, &
                                             base_top - real(i - 1, wp)*(ax_h + gap_h))
                end if
                call render_subplot_title_ascii(backend, subplots_array(i, j)%title)
            end do
        end do
    end subroutine render_ascii_subplot_titles

    subroutine render_subplot_title_ascii(backend, title)
        class(plot_context), intent(inout) :: backend
        character(len=*), intent(in) :: title
        real(wp) :: x_center, y_pos
        character(len=500) :: processed_title
        integer :: processed_len, col, row, i

        if (len_trim(title) == 0) return

        select type (bk => backend)
        class is (ascii_context)
            call sanitize_ascii_text(title, processed_title, processed_len)
            x_center = real(bk%plot_area%left, wp) + 0.5_wp*real(bk%plot_area%width, wp)
            y_pos = real(max(1, bk%plot_area%bottom - 1), wp)
            row = max(1, min(nint(y_pos), bk%plot_height))
            col = max(1, min(nint(x_center), bk%plot_width - processed_len - 1))
            do i = 1, processed_len
                if (col + i - 1 <= bk%plot_width - 1) then
                    bk%canvas(row, col + i - 1) = processed_title(i:i)
                end if
            end do
        class default
        end select
    end subroutine render_subplot_title_ascii

    subroutine render_suptitle(state, suptitle_height_frac)
        !! Render the figure-level suptitle above all subplots
        !! suptitle_height_frac is the fractional height reserved for suptitle
        !! in the figure layout (used to position the title above subplot area)
        type(figure_state_t), intent(inout) :: state
        real(wp), intent(in) :: suptitle_height_frac

        real(wp) :: suptitle_y_frac, center_x
        integer :: suptitle_y_px
        real(wp) :: font_scale
        real(wp) :: clearance_frac

        if (.not. allocated(state%suptitle)) return
        if (len_trim(state%suptitle) == 0) return

        font_scale = state%suptitle_fontsize/12.0_wp

        ! Clearance between suptitle baseline and subplot top area
        clearance_frac = 0.01_wp

        select type (bk => state%backend)
        class is (raster_context)
            ! Raster y grows downward (y=0 at top), so the suptitle baseline sits
            ! within the reserved top band: a small clearance below the top edge
            ! plus the title's own height.
            suptitle_y_frac = suptitle_height_frac + clearance_frac
            suptitle_y_px = int(real(bk%height, wp)*suptitle_y_frac)
            center_x = real(bk%width, wp)/2.0_wp
            call render_title_centered_with_size(bk%raster, bk%width, bk%height, &
                                                 int(center_x), suptitle_y_px, &
                                                 trim(state%suptitle), font_scale)
        class is (pdf_context)
            block
                real(wp) :: title_width, x_center, y_pos
                real(wp) :: scaled_font_size
                real(wp) :: black_color(3)

                scaled_font_size = PDF_TITLE_SIZE*font_scale
                title_width = estimate_pdf_text_width(trim(state%suptitle), &
                                                      scaled_font_size)
                x_center = real(bk%width, wp)/2.0_wp
                ! Position suptitle at top of figure minus its own height and clearance
                y_pos = real(bk%height, wp)*(1.0_wp - suptitle_height_frac - clearance_frac)
                black_color = [0.0_wp, 0.0_wp, 0.0_wp]
                call bk%draw_text_styled(x_center, y_pos, trim(state%suptitle), &
                                         scaled_font_size, 0.0_wp, 'center', 'bottom', &
                                         .false., black_color)
            end block
        class is (ascii_context)
            call bk%set_title(trim(state%suptitle))
        class default
        end select
    end subroutine render_suptitle

    subroutine restore_figure_legend_frame(backend, left_f, right_f, bottom_f, top_f)
        class(plot_context), intent(inout) :: backend
        real(wp), intent(in) :: left_f, right_f, bottom_f, top_f

        select type (bk => backend)
        class is (raster_context)
            bk%margins%left = left_f
            bk%margins%right = right_f
            bk%margins%bottom = bottom_f
            bk%margins%top = top_f
            call calculate_plot_area(bk%width, bk%height, bk%margins, bk%plot_area)
        class is (pdf_context)
            bk%margins%left = left_f
            bk%margins%right = right_f
            bk%margins%bottom = bottom_f
            bk%margins%top = top_f
            call calculate_pdf_plot_area(bk%width, bk%height, bk%margins, bk%plot_area)
        class is (ascii_context)
            bk%margins%left = left_f
            bk%margins%right = right_f
            bk%margins%bottom = bottom_f
            bk%margins%top = top_f
            call calculate_plot_area(bk%plot_width, bk%plot_height, bk%margins, &
                                     bk%plot_area)
        class default
        end select
    end subroutine restore_figure_legend_frame

    subroutine apply_subplot_legend_bounds(backend, subplots_array, nr, nc)
        class(plot_context), intent(inout) :: backend
        type(subplot_data_t), intent(in) :: subplots_array(:, :)
        integer, intent(in) :: nr, nc

        integer :: i, j, k
        logical :: have_bounds
        real(wp) :: x_min, x_max, y_min, y_max
        real(wp) :: x_min_local, x_max_local, y_min_local, y_max_local

        have_bounds = .false.
        do i = 1, nr
            do j = 1, nc
                do k = 1, subplots_array(i, j)%plot_count
                    if (.not. allocated(subplots_array(i, j)%plots(k)%x)) cycle
                    if (.not. allocated(subplots_array(i, j)%plots(k)%y)) cycle
                    x_min_local = minval(subplots_array(i, j)%plots(k)%x)
                    x_max_local = maxval(subplots_array(i, j)%plots(k)%x)
                    y_min_local = minval(subplots_array(i, j)%plots(k)%y)
                    y_max_local = maxval(subplots_array(i, j)%plots(k)%y)
                    if (.not. have_bounds) then
                        x_min = x_min_local
                        x_max = x_max_local
                        y_min = y_min_local
                        y_max = y_max_local
                        have_bounds = .true.
                    else
                        x_min = min(x_min, x_min_local)
                        x_max = max(x_max, x_max_local)
                        y_min = min(y_min, y_min_local)
                        y_max = max(y_max, y_max_local)
                    end if
                end do
            end do
        end do

        if (.not. have_bounds) return
        backend%x_min = x_min
        backend%x_max = x_max
        backend%y_min = y_min
        backend%y_max = y_max
    end subroutine apply_subplot_legend_bounds

    function compute_suptitle_height_frac(state, fig_w, fig_h) result(h_frac)
        !! Compute the fractional height that the suptitle occupies in the figure.
        !! Returns 0.0 if no suptitle is set.
        type(figure_state_t), intent(in) :: state
        real(wp), intent(out) :: fig_w, fig_h
        real(wp) :: h_frac

        real(wp) :: suptitle_height_px
        real(wp) :: font_scale
        real(wp) :: scaled_font_size

        h_frac = 0.0_wp
        fig_w = 1.0_wp
        fig_h = 1.0_wp

        if (.not. allocated(state%suptitle)) return
        if (len_trim(state%suptitle) == 0) return

        font_scale = state%suptitle_fontsize/12.0_wp

        select type (bk => state%backend)
        class is (raster_context)
            fig_w = real(bk%width, wp)
            fig_h = real(bk%height, wp)
            scaled_font_size = TITLE_FONT_SIZE_PT * bk%raster%dpi / 72.0_wp * font_scale
            suptitle_height_px = real(calculate_text_height_with_size( &
                scaled_font_size), wp)
            if (suptitle_height_px > 0 .and. fig_h > 0) then
                h_frac = suptitle_height_px/fig_h
            end if
            ! Minimum clearance: ensure at least 2% of figure height
            h_frac = max(h_frac, 0.02_wp)
        class is (pdf_context)
            fig_w = real(bk%width, wp)
            fig_h = real(bk%height, wp)
            scaled_font_size = PDF_TITLE_SIZE*font_scale
            ! Estimate suptitle height in PDF points
            suptitle_height_px = scaled_font_size*1.2_wp
            if (fig_h > 0) then
                ! Convert PDF points to figure-height fraction
                ! PDF uses points (1/72 inch), figure height is in inches at given DPI
                h_frac = suptitle_height_px/fig_h
            end if
            h_frac = max(h_frac, 0.02_wp)
        class default
        end select
    end function compute_suptitle_height_frac

    subroutine set_subplot_margins(backend, left_f, right_f, bottom_f, top_f)
        class(*), intent(inout) :: backend
        real(wp), intent(in) :: left_f, right_f, bottom_f, top_f

        select type (bk => backend)
        class is (raster_context)
            bk%margins%left = left_f
            bk%margins%right = right_f
            bk%margins%bottom = bottom_f
            bk%margins%top = top_f
            call calculate_plot_area(bk%width, bk%height, bk%margins, bk%plot_area)
        class is (pdf_context)
            bk%margins%left = left_f
            bk%margins%right = right_f
            bk%margins%bottom = bottom_f
            bk%margins%top = top_f
            call calculate_pdf_plot_area(bk%width, bk%height, bk%margins, bk%plot_area)
        class is (ascii_context)
            bk%margins%left = left_f
            bk%margins%right = right_f
            bk%margins%bottom = bottom_f
            bk%margins%top = top_f
            call calculate_plot_area(bk%plot_width, bk%plot_height, bk%margins, &
                                     bk%plot_area)
        class default
        end select
    end subroutine set_subplot_margins

end module fortplot_subplot_rendering