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_subplot_colorbar, only: panel_colorbar_t, begin_panel_colorbar, & render_panel_colorbar use fortplot_subplot_ticks, only: apply_subplot_ticks use fortplot_svg, only: svg_context 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_subplot_legends, only: render_subplot_legend use fortplot_context, only: plot_context use fortplot_figure_aspect, only: equalize_aspect_limits use fortplot_annotations, only: text_annotation_t use fortplot_annotation_rendering, only: render_figure_annotations use fortplot_figure_render_steps, only: axes_box_pixels implicit none private public :: render_subplots contains subroutine render_subplots(state, subplots_array, subplot_rows, subplot_cols, & annotations, annotation_count) type(figure_state_t), intent(inout) :: state type(subplot_data_t), intent(in) :: subplots_array(:, :) integer, intent(in) :: subplot_rows, subplot_cols type(text_annotation_t), intent(in), optional :: annotations(:) integer, intent(in), optional :: annotation_count 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. if (state%tight_layout_enabled) then 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) end if 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) call render_panel_annotations(state, (i - 1)*nc + j, & annotations, annotation_count) 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) call render_panel_annotations(state, 0, annotations, annotation_count) 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, axes_after_data type(panel_colorbar_t) :: cbar ! 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 apply_subplot_ticks(state%backend, sp) ! A panel colorbar takes its share of this panel's axes box. call begin_panel_colorbar(state%backend, sp, cbar) lxmin = sp%x_min; lxmax = sp%x_max lymin = sp%y_min; lymax = sp%y_max 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, & sp%xscale, sp%yscale, & sp%symlog_threshold, & sp%symlog_base, sp%symlog_linscale) call apply_panel_aspect(state%backend, sp, lxmin, lxmax, lymin, lymax, & lxmin_t, lxmax_t, lymin_t, lymax_t) 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 ! Spines sit above 2D data (matplotlib zorder 2.5), as on single axes, ! so data clipped to the axes box cannot cover them. Text grids are ! cell based and keep the axes underneath. axes_after_data = .true. select type (bk => state%backend) class is (ascii_context) axes_after_data = .false. class default end select if (.not. axes_after_data) call render_cell_axes() 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, & sp%xscale, sp%yscale, & sp%symlog_threshold, state%width, & state%height, & state%margin_left, state%margin_right, & state%margin_bottom, state%margin_top) end if if (axes_after_data) call render_cell_axes() call render_figure_axes_labels_only(state%backend, sp%xscale, & sp%yscale, sp%symlog_threshold, & lxmin, lxmax, lymin, lymax, & sp%title, sp%xlabel, sp%ylabel, & sp%plots, sp%plot_count, & custom_xticks=sp%custom_xtick_positions, & custom_xtick_labels=sp%custom_xtick_labels, & custom_yticks=sp%custom_ytick_positions, & custom_ytick_labels=sp%custom_ytick_labels, & has_twinx=.false., has_twiny=.false., & x_date_format=trim(x_date_format), & y_date_format=trim(y_date_format)) call render_subplot_legend(state%backend, sp, state%backend_name) call render_panel_colorbar(state%backend, sp, cbar, state%current_line_width) contains subroutine render_cell_axes() call render_figure_axes(state%backend, sp%xscale, sp%yscale, & sp%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) end subroutine render_cell_axes end subroutine render_subplot_cell subroutine render_panel_annotations(state, axes_index, annotations, & annotation_count) !! text()/annotate() of one subplot (axes_index > 0, drawn in the !! frame the cell left on the backend) or the figure-coordinate ones. type(figure_state_t), intent(inout) :: state integer, intent(in) :: axes_index type(text_annotation_t), intent(in), optional :: annotations(:) integer, intent(in), optional :: annotation_count if (.not. present(annotations)) return if (.not. present(annotation_count)) return if (annotation_count <= 0) return call render_figure_annotations(state%backend, annotations, & annotation_count, state%backend%x_min, & state%backend%x_max, state%backend%y_min, & state%backend%y_max, state%width, & state%height, state%dpi, state%margin_left, & state%margin_right, state%margin_bottom, & state%margin_top, axes_index=axes_index) end subroutine render_panel_annotations subroutine apply_panel_aspect(backend, sp, xmin, xmax, ymin, ymax, & xmin_t, xmax_t, ymin_t, ymax_t) !! Honour a per-panel axis('equal') / set_aspect by widening one data !! range to the panel's own axes box (linear axes only). class(plot_context), intent(in) :: backend type(subplot_data_t), intent(in) :: sp real(wp), intent(inout) :: xmin, xmax, ymin, ymax real(wp), intent(inout) :: xmin_t, xmax_t, ymin_t, ymax_t real(wp) :: pw, ph if (trim(sp%aspect_mode) == 'auto') return if (trim(sp%xscale) /= 'linear' .or. trim(sp%yscale) /= 'linear') return if (.not. axes_box_pixels(backend, pw, ph)) return call equalize_aspect_limits(sp%aspect_mode, sp%aspect_ratio, pw, ph, & xmin, xmax, ymin, ymax) xmin_t = xmin; xmax_t = xmax ymin_t = ymin; ymax_t = ymax end subroutine apply_panel_aspect 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 = 1.0_wp - right_f bk%margins%bottom = bottom_f bk%margins%top = 1.0_wp - 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 = 1.0_wp - right_f bk%margins%bottom = bottom_f bk%margins%top = 1.0_wp - 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 = 1.0_wp - right_f bk%margins%bottom = bottom_f bk%margins%top = 1.0_wp - 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 call backend%set_coordinates(x_min, x_max, y_min, 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 (svg_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 (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