fortplot_raster_labels.f90 Source File


Source Code

module fortplot_raster_labels
    !! Raster axis labels (title, xlabel, ylabel) rendering functionality
    !! Extracted from fortplot_raster_axes.f90 for single responsibility principle
    use fortplot_constants, only: XLABEL_VERTICAL_OFFSET, TICK_MARK_LENGTH, &
                                  YLABEL_EXTRA_GAP, TITLE_VERTICAL_OFFSET, &
                                  REFERENCE_DPI, FALLBACK_LABEL_HEIGHT_PX, &
                                   MIN_LABEL_MARGIN_PX, CANVAS_EDGE_PADDING_PX
   use fortplot_text_rendering, only: render_text_to_image, calculate_text_width, &
                                         calculate_text_height, &
                                         calculate_text_descent, &
                                         calculate_text_width_with_size, &
                                         render_text_with_size, TITLE_FONT_SIZE_PT
    use fortplot_text_fonts, only: get_font_ascent_ratio
    use fortplot_text_helpers, only: prepare_text_for_raster
    use fortplot_margins, only: plot_area_t
    use fortplot_raster_core, only: raster_image_t, scale_px
    use fortplot_bitmap, only: render_text_to_bitmap, rotate_bitmap_90_ccw, &
                               rotate_bitmap_90_cw, composite_bitmap_to_raster
    use fortplot_raster_ticks, only: &
                                     Y_TICK_LABEL_RIGHT_PAD, &
                                     Y_TICK_LABEL_LEFT_PAD, X_TICK_LABEL_TOP_PAD, &
                                     X_TICK_LABEL_PAD
    use, intrinsic :: iso_fortran_env, only: wp => real64
    implicit none

    private
    public :: raster_draw_axis_labels
    public :: raster_render_ylabel
    public :: raster_render_ylabel_right
    public :: raster_draw_top_xlabel
    public :: render_title_centered
    public :: render_title_centered_with_size
    public :: compute_title_position
    public :: compute_ylabel_x_pos
    public :: y_tick_label_right_edge_at_axis

contains

    subroutine raster_draw_axis_labels(raster, width, height, plot_area, title, &
                                       xlabel, ylabel)
        !! Draw all axis labels (title, xlabel, ylabel)
        type(raster_image_t), intent(inout) :: raster
        integer, intent(in) :: width, height
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: title, xlabel, ylabel
        character(len=600) :: escaped_text
        integer :: label_x, label_y
        integer :: label_width, label_height

        ! Title at top
        if (len_trim(title) > 0) then
            call render_title_centered(raster, width, height, &
                plot_area, title, raster%config_title_font_size)
        end if

        ! X label at bottom
        if (len_trim(xlabel) > 0) then
            call prepare_text_for_raster(xlabel, escaped_text)
            label_width = calculate_text_width(trim(escaped_text))
            label_height = calculate_text_height(trim(escaped_text))
            label_x = plot_area%left + plot_area%width/2 - label_width/2
            ! Position xlabel below x-tick labels with measured clearance
            label_y = plot_area%bottom + plot_area%height + &
                      scale_px(X_TICK_LABEL_PAD, raster%dpi) + &
                      max(raster%last_x_tick_max_height_bottom, FALLBACK_LABEL_HEIGHT_PX) + &
                      scale_px(XLABEL_VERTICAL_OFFSET, raster%dpi)/3
            label_y = min(label_y, height - label_height - CANVAS_EDGE_PADDING_PX)
            call render_text_to_image(raster%image_data, width, height, &
                                      label_x, label_y, &
                                      trim(escaped_text), 0_1, 0_1, 0_1)
        end if

        ! Y label at left
        if (len_trim(ylabel) > 0) then
            call raster_render_ylabel(raster, width, height, plot_area, ylabel)
        end if
    end subroutine raster_draw_axis_labels

    subroutine raster_render_ylabel(raster, width, height, plot_area, ylabel)
        !! Render rotated ylabel to the left of y-axis
        type(raster_image_t), intent(inout) :: raster
        integer, intent(in) :: width, height
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: ylabel
        character(len=600) :: escaped_text
        integer(1), allocatable :: text_bitmap(:, :, :), rotated_bitmap(:, :, :)
        integer :: text_width, text_height, text_descent
        integer :: rotated_width, rotated_height
        integer :: target_x, target_y
        integer :: y_tick_label_edge

        if (len_trim(ylabel) == 0) return

        call prepare_text_for_raster(ylabel, escaped_text)

        text_width = calculate_text_width(trim(escaped_text))
        text_height = calculate_text_height(trim(escaped_text))
        text_descent = calculate_text_descent(trim(escaped_text))

        ! Allocate text bitmap
        allocate (text_bitmap(text_width, text_height, 3))
        text_bitmap = -1_1  ! Initialize to white

        ! Render text to bitmap (upright).
        ! Position baseline to leave room for descenders.
        call render_text_to_bitmap(text_bitmap, text_width, text_height, 0, &
                                   text_height - text_descent, &
                                   trim(escaped_text))

        ! Rotate 90 degrees counter-clockwise
        rotated_width = text_height
        rotated_height = text_width
        allocate (rotated_bitmap(rotated_width, rotated_height, 3))
        call rotate_bitmap_90_ccw(text_bitmap, rotated_bitmap, text_width, text_height)

        ! Compute the rightmost edge of y-tick labels
        y_tick_label_edge = y_tick_label_right_edge_at_axis(plot_area, &
                                                            raster%last_y_tick_max_width, &
                                                            raster%dpi)

        ! Compute ylabel position with dynamic gap
        target_x = compute_ylabel_x_pos(y_tick_label_edge, rotated_width, raster%dpi)

        ! Center vertically in plot area
        target_y = plot_area%bottom + plot_area%height/2 - rotated_height/2

        ! Composite to raster
        call composite_bitmap_to_raster(raster%image_data, width, height, &
                                        rotated_bitmap, &
                                        rotated_width, rotated_height, &
                                        target_x, target_y)

    end subroutine raster_render_ylabel

    integer function y_tick_label_left_edge_at_axis(plot_area, max_width_measured, dpi)
        !! Compute the leftmost edge of right-side y-tick labels relative to the axis
        use, intrinsic :: iso_fortran_env, only: wp => real64
        type(plot_area_t), intent(in) :: plot_area
        integer, intent(in) :: max_width_measured
        real(wp), intent(in), optional :: dpi
        real(wp) :: dpi_val
        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        y_tick_label_left_edge_at_axis = plot_area%left + plot_area%width + &
                                         scale_px(TICK_MARK_LENGTH, dpi_val) + &
                                         scale_px(Y_TICK_LABEL_LEFT_PAD, dpi_val)
    end function y_tick_label_left_edge_at_axis

    integer function compute_ylabel_right_x_pos(y_tick_label_edge, rotated_width, &
                                                plot_area, canvas_width, dpi)
        !! Compute x-position for right-side ylabel avoiding overlap with tick labels
        use, intrinsic :: iso_fortran_env, only: wp => real64
        integer, intent(in) :: y_tick_label_edge
        integer, intent(in) :: rotated_width
        type(plot_area_t), intent(in) :: plot_area
        integer, intent(in) :: canvas_width
        real(wp), intent(in), optional :: dpi
        real(wp) :: dpi_val
        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        compute_ylabel_right_x_pos = y_tick_label_edge + &
                                     scale_px(YLABEL_EXTRA_GAP, dpi_val)
        if (compute_ylabel_right_x_pos + rotated_width > canvas_width - MIN_LABEL_MARGIN_PX) then
            compute_ylabel_right_x_pos = max(plot_area%left + plot_area%width + CANVAS_EDGE_PADDING_PX, &
                                             canvas_width - rotated_width - MIN_LABEL_MARGIN_PX)
        end if
    end function compute_ylabel_right_x_pos

    subroutine raster_render_ylabel_right(raster, width, height, plot_area, ylabel)
        !! Render rotated ylabel along the right side of the axis
        type(raster_image_t), intent(inout) :: raster
        integer, intent(in) :: width, height
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: ylabel
        character(len=600) :: escaped_text
        integer(1), allocatable :: text_bitmap(:, :, :), rotated_bitmap(:, :, :)
        integer :: text_width, text_height, text_descent
        integer :: rotated_width, rotated_height
        integer :: target_x, target_y
        integer :: y_tick_label_edge

        if (len_trim(ylabel) == 0) return

        call prepare_text_for_raster(ylabel, escaped_text)

        text_width = calculate_text_width(trim(escaped_text))
        text_height = calculate_text_height(trim(escaped_text))
        text_descent = calculate_text_descent(trim(escaped_text))

        allocate (text_bitmap(text_width, text_height, 3))
        text_bitmap = -1_1

        call render_text_to_bitmap(text_bitmap, text_width, text_height, 0, &
                                   text_height - text_descent, &
                                   trim(escaped_text))

        rotated_width = text_height
        rotated_height = text_width
        allocate (rotated_bitmap(rotated_width, rotated_height, 3))
        call rotate_bitmap_90_cw(text_bitmap, rotated_bitmap, text_width, text_height)

        y_tick_label_edge = y_tick_label_left_edge_at_axis(plot_area, &
                                                           raster%last_y_tick_max_width_right, &
                                                           raster%dpi)
        target_x = compute_ylabel_right_x_pos(y_tick_label_edge, rotated_width, &
                                              plot_area, width, raster%dpi)
        target_y = plot_area%bottom + plot_area%height/2 - rotated_height/2

        call composite_bitmap_to_raster(raster%image_data, width, height, &
                                        rotated_bitmap, &
                                        rotated_width, rotated_height, &
                                        target_x, target_y)

    end subroutine raster_render_ylabel_right

    integer function y_tick_label_right_edge_at_axis(plot_area, max_width_measured, &
                                                    dpi)
        !! Compute the rightmost edge of y-tick labels relative to the y-axis
        use, intrinsic :: iso_fortran_env, only: wp => real64
        type(plot_area_t), intent(in) :: plot_area
        integer, intent(in) :: max_width_measured
        real(wp), intent(in), optional :: dpi
        real(wp) :: dpi_val
        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        y_tick_label_right_edge_at_axis = plot_area%left - &
                                          scale_px(TICK_MARK_LENGTH, dpi_val) - &
                                          scale_px(Y_TICK_LABEL_RIGHT_PAD, dpi_val) - &
                                          max(0, max_width_measured)
    end function y_tick_label_right_edge_at_axis

    integer function compute_ylabel_x_pos(y_tick_label_edge, rotated_width, dpi)
        !! Compute x-position for ylabel to avoid overlapping with y-tick labels
        use, intrinsic :: iso_fortran_env, only: wp => real64
        integer, intent(in) :: y_tick_label_edge
        integer, intent(in) :: rotated_width
        real(wp), intent(in), optional :: dpi

        integer :: min_left_margin
        integer :: ideal_x
        integer :: safe_x
        real(wp) :: dpi_val
        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        min_left_margin = max(MIN_LABEL_MARGIN_PX, rotated_width/4)

        ideal_x = y_tick_label_edge - scale_px(YLABEL_EXTRA_GAP, dpi_val) - &
                  rotated_width

        ! If tick labels are already off-canvas, favor keeping the label visible.
        if (y_tick_label_edge <= 0) then
            compute_ylabel_x_pos = max(min_left_margin, ideal_x)
            return
        end if

        if (ideal_x >= min_left_margin) then
            compute_ylabel_x_pos = ideal_x
            return
        end if

        ! If there is not enough room to keep both a minimum margin and the
        ! extra gap, drop the extra gap first; if still no room, allow the
        ! ylabel to extend off-canvas to avoid overlapping tick labels.
        safe_x = y_tick_label_edge - rotated_width - 1
        if (safe_x >= min_left_margin) then
            compute_ylabel_x_pos = min_left_margin
        else
            compute_ylabel_x_pos = safe_x
        end if
    end function compute_ylabel_x_pos

    integer function compute_top_xlabel_y_pos(raster, plot_area, label_height, dpi)
        !! Compute y-position for an x-label rendered above the axis
        use, intrinsic :: iso_fortran_env, only: wp => real64
        type(raster_image_t), intent(in) :: raster
        type(plot_area_t), intent(in) :: plot_area
        integer, intent(in) :: label_height
        real(wp), intent(in), optional :: dpi
        real(wp) :: dpi_val
        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        compute_top_xlabel_y_pos = max(1, plot_area%bottom - &
                                       scale_px(X_TICK_LABEL_TOP_PAD, dpi_val) - &
                                       raster%last_x_tick_max_height_top - label_height - CANVAS_EDGE_PADDING_PX)
    end function compute_top_xlabel_y_pos

    subroutine raster_draw_top_xlabel(raster, width, height, plot_area, xlabel)
        !! Render an xlabel centered above the plot area
        type(raster_image_t), intent(inout) :: raster
        integer, intent(in) :: width, height
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: xlabel
        character(len=600) :: escaped_text
        integer :: label_width, label_height
        integer :: label_x, label_y

        if (len_trim(xlabel) == 0) return

        call prepare_text_for_raster(xlabel, escaped_text)

        label_width = calculate_text_width(trim(escaped_text))
        label_height = calculate_text_height(trim(escaped_text))
        if (label_height <= 0) label_height = FALLBACK_LABEL_HEIGHT_PX

        label_x = plot_area%left + plot_area%width/2 - label_width/2
        label_y = compute_top_xlabel_y_pos(raster, plot_area, label_height, raster%dpi)

        call render_text_to_image(raster%image_data, width, height, label_x, label_y, &
                                  trim(escaped_text), 0_1, 0_1, 0_1)
    end subroutine raster_draw_top_xlabel

   subroutine render_title_centered(raster, width, height, plot_area, &
                                      title_text, custom_font_size)
        !! Render title centered above the plot area
        type(raster_image_t), intent(inout) :: raster
        integer, intent(in) :: width, height
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: title_text
        real(wp), intent(in), optional :: custom_font_size
        character(len=600) :: escaped_text
        integer :: title_px, title_py
        real(wp) :: title_px_real, title_py_real, fsize

        if (len_trim(title_text) == 0) return

        fsize = TITLE_FONT_SIZE_PT * raster%dpi / 72.0_wp
        if (present(custom_font_size)) then
            if (custom_font_size > 0.0_wp) fsize = custom_font_size
        end if

        call compute_title_position_sized(plot_area, title_text, &
            escaped_text, title_px_real, title_py_real, &
            fsize, raster%dpi, raster%last_x_tick_max_height_top)

        title_px = int(title_px_real)
        title_py = int(title_py_real)

        call render_text_with_size(raster%image_data, width, height, &
                                   title_px, title_py, &
                                   trim(escaped_text), 0_1, 0_1, 0_1, &
                                   fsize)
    end subroutine render_title_centered

  subroutine compute_title_position(plot_area, title_text, escaped_text, &
                                       title_px, title_py, dpi, &
                                       top_tick_height)
        !! Compute the position for centered title above plot area
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: title_text
        character(len=*), intent(out) :: escaped_text
        real(wp), intent(out) :: title_px, title_py
        real(wp), intent(in), optional :: dpi
        integer, intent(in), optional :: top_tick_height
        integer :: tick_h
        real(wp) :: dpi_val

        tick_h = -1
        if (present(top_tick_height)) tick_h = top_tick_height

        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        call compute_title_position_sized(plot_area, title_text, &
            escaped_text, title_px, title_py, &
            TITLE_FONT_SIZE_PT * dpi_val / 72.0_wp, &
            dpi_val, tick_h)
    end subroutine compute_title_position

   subroutine compute_title_position_sized(plot_area, title_text, &
                                             escaped_text, title_px, &
                                             title_py, fsize, dpi, &
                                             top_tick_height)
        !! Compute centered title position with explicit font size.
        !! When top_tick_height > 0 (twiny active), the title is placed
        !! above the top-axis xlabel to avoid overlap with tick labels
        !! and the top x-axis label.
        type(plot_area_t), intent(in) :: plot_area
        character(len=*), intent(in) :: title_text
        character(len=*), intent(out) :: escaped_text
        real(wp), intent(out) :: title_px, title_py
        real(wp), intent(in) :: fsize
        real(wp), intent(in), optional :: dpi
        integer, intent(in) :: top_tick_height
        integer :: title_width, title_height
        real(wp) :: dpi_val
        integer :: top_xlabel_y, title_gap

        dpi_val = REFERENCE_DPI
        if (present(dpi)) dpi_val = dpi

        call prepare_text_for_raster(title_text, escaped_text)

        title_width = calculate_text_width_with_size( &
            trim(escaped_text), fsize)
        title_height = max(1, int(fsize))

        title_px = real(plot_area%left + plot_area%width / 2 &
                        - title_width / 2, wp)

        if (top_tick_height > 0) then
            ! twiny active: place title above the top xlabel
            top_xlabel_y = plot_area%bottom - &
                           scale_px(X_TICK_LABEL_TOP_PAD, dpi_val) - &
                           top_tick_height - title_height - &
                           CANVAS_EDGE_PADDING_PX
            title_gap = TITLE_VERTICAL_OFFSET
            title_py = real(max(1, top_xlabel_y - title_gap - title_height), wp)
        else
            title_py = real(plot_area%bottom - TITLE_VERTICAL_OFFSET, wp)
            title_py = max(1.0_wp, title_py)
        end if
    end subroutine compute_title_position_sized

    subroutine render_title_centered_with_size(raster, width, height, center_x, &
                                               title_y, title_text, font_scale)
        !! Render title centered at specified position with custom font scale
        !! Used for suptitle rendering above subplots
        type(raster_image_t), intent(inout) :: raster
        integer, intent(in) :: width, height
        integer, intent(in) :: center_x, title_y
        character(len=*), intent(in) :: title_text
        real(wp), intent(in) :: font_scale
        character(len=600) :: escaped_text
        integer :: title_width, title_px
        real(wp) :: scaled_font_size

        if (len_trim(title_text) == 0) return

        call prepare_text_for_raster(title_text, escaped_text)

        scaled_font_size = TITLE_FONT_SIZE_PT * raster%dpi / 72.0_wp * font_scale
        title_width = calculate_text_width_with_size(trim(escaped_text), &
                                                     scaled_font_size)
        title_px = center_x - title_width / 2

        call render_text_with_size(raster%image_data, width, height, title_px, &
                                   title_y, trim(escaped_text), 0_1, 0_1, 0_1, &
                                   scaled_font_size)
    end subroutine render_title_centered_with_size

end module fortplot_raster_labels