fortplot_legend_layout.f90 Source File


Source Code

module fortplot_legend_layout
    !! Shared legend layout calculations following DRY principle
    !! 
    !! Single Responsibility: Legend box sizing and positioning calculations
    !! DRY: Centralized legend layout logic for consistent behavior
    !! KISS: Simple, clear calculation functions
    !! Uses text system measurements for accurate sizing
    
    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_text, only: calculate_text_width, calculate_text_height, init_text_system
    use fortplot_constants, only: STANDARD_WIDTH_PIXELS, STANDARD_HEIGHT_PIXELS, TEXT_WIDTH_RATIO
    use fortplot_legend_overlap, only: legend_overlap_cost
    use fortplot_text_fonts, only: get_global_font, get_font_scale
    use fortplot_truetype, only: truetype_font_t
    use fortplot_text_layout, only: has_mathtext
    use fortplot_latex_parser, only: process_latex_in_text
    implicit none
    
    private
    public :: legend_box_t, calculate_legend_box, calculate_optimal_legend_dimensions
    public :: get_actual_text_dimensions, get_legend_margins
    public :: choose_best_legend_position
    
    ! Local position constants mirror fortplot_legend_state to avoid a cyclic
    ! module dependency (state has no dependency on this module, and adding one
    ! would close a cycle through fortplot_legend_drawing).
    integer, parameter :: LEGEND_UPPER_LEFT = 1
    integer, parameter :: LEGEND_UPPER_RIGHT = 2
    integer, parameter :: LEGEND_LOWER_LEFT = 3
    integer, parameter :: LEGEND_LOWER_RIGHT = 4
    integer, parameter :: LEGEND_EAST = 5
    integer, parameter :: LEGEND_RIGHT = 6
    integer, parameter :: LEGEND_CENTER_LEFT = 7
    integer, parameter :: LEGEND_CENTER_RIGHT = 8
    integer, parameter :: LEGEND_LOWER_CENTER = 9
    integer, parameter :: LEGEND_UPPER_CENTER = 10
    integer, parameter :: LEGEND_CENTER = 11

    type :: legend_box_t
        !! Single Responsibility: Legend box dimensions and position
        real(wp) :: x, y              ! Top-left corner position
        real(wp) :: width, height     ! Box dimensions  
        real(wp) :: padding           ! Vertical padding (Y)
        real(wp) :: padding_x         ! Horizontal padding (X)
        real(wp) :: entry_height      ! Height of each legend entry (text height)
        real(wp) :: entry_spacing     ! Vertical spacing between entries
        real(wp) :: line_length       ! Length of legend line samples
        real(wp) :: text_spacing      ! Space between line and text
        real(wp) :: handle_height     ! Matplotlib 0.7-font-size handle box
    end type legend_box_t
    
contains

    function calculate_legend_box(labels, data_width, data_height, num_entries, position, &
                                  pixel_plot_width, pixel_plot_height) result(box)
        !! Calculate optimal legend box dimensions and position
        !! DRY: Centralized legend box calculation logic
        character(len=*), intent(in) :: labels(:)
        real(wp), intent(in) :: data_width, data_height
        integer, intent(in) :: num_entries, position
        integer, intent(in), optional :: pixel_plot_width, pixel_plot_height
        type(legend_box_t) :: box
        real(wp) :: max_text_width, total_text_width, margins(2)
        integer :: px_w, px_h
        
        if (num_entries == 0) then
            box%width = 0.0_wp
            box%height = 0.1_wp
            return
        end if
        
        ! Resolve pixel plot area dimensions if provided (prefer exact values)
        if (present(pixel_plot_width) .and. present(pixel_plot_height)) then
            px_w = max(1, pixel_plot_width)
            px_h = max(1, pixel_plot_height)
        else
            px_w = int(STANDARD_WIDTH_PIXELS)
            px_h = int(STANDARD_HEIGHT_PIXELS)
        end if

        ! Calculate optimal dimensions based on content with real pixel scale
        call calculate_optimal_legend_dimensions(labels, data_width, data_height, &
                                                max_text_width, total_text_width, box, px_w, px_h)
        
        ! Get appropriate margins for this backend
        margins = get_legend_margins(data_width, data_height, px_w, px_h)

        ! Calculate position based on legend location
        call calculate_legend_position(box, data_width, data_height, position, margins)

    end function calculate_legend_box

    function choose_best_legend_position(labels, data_width, data_height, &
                                         num_entries, artist_x, artist_y, &
                                         pixel_plot_width, pixel_plot_height, &
                                         artist_paths, rectangles) result(position)
        !! Resolve matplotlib 'best' placement across all ten anchored positions.
        !! Score covered vertices, intersecting paths, offsets and patch bounds;
        !! ties follow Matplotlib location-code order (upper right first).
        character(len=*), intent(in) :: labels(:)
        real(wp), intent(in) :: data_width, data_height
        integer, intent(in) :: num_entries
        real(wp), intent(in) :: artist_x(:), artist_y(:)
        integer, intent(in), optional :: pixel_plot_width, pixel_plot_height
        integer :: position
        integer, intent(in), optional :: artist_paths(:)
        real(wp), intent(in), optional :: rectangles(:, :)

        ! Order encodes the tie-break preference (matplotlib: upper right first).
        integer, parameter :: candidates(10) = &
            [LEGEND_UPPER_RIGHT, LEGEND_UPPER_LEFT, &
             LEGEND_LOWER_LEFT, LEGEND_LOWER_RIGHT, LEGEND_RIGHT, &
             LEGEND_CENTER_LEFT, LEGEND_CENTER_RIGHT, LEGEND_LOWER_CENTER, &
             LEGEND_UPPER_CENTER, LEGEND_CENTER]
        type(legend_box_t) :: box
        integer :: i, overlap, best_overlap
        real(wp) :: margins(2)

        position = LEGEND_UPPER_RIGHT
        best_overlap = huge(0)

        box = calculate_legend_box(labels, data_width, data_height, &
                                   num_entries, candidates(1), &
                                   pixel_plot_width, pixel_plot_height)
        margins = [data_width - box%width - box%x, data_height - box%y]
        do i = 1, size(candidates)
            call calculate_legend_position(box, data_width, data_height, &
                                           candidates(i), margins)
            overlap = legend_overlap_cost( &
                [box%x, box%y - box%height, box%x + box%width, box%y], &
                artist_x, artist_y, artist_paths, rectangles)
            if (overlap < best_overlap) then
                best_overlap = overlap
                position = candidates(i)
            end if
            if (overlap == 0) exit
        end do
    end function choose_best_legend_position

    subroutine calculate_optimal_legend_dimensions(labels, data_width, data_height, &
                                                  max_text_width, total_text_width, box, &
                                                  pixel_plot_width, pixel_plot_height)
        !! Calculate optimal legend dimensions using actual text system measurements
        !! KISS: Based on measured text content, not estimates
        character(len=*), intent(in) :: labels(:)
        real(wp), intent(in) :: data_width, data_height
        real(wp), intent(out) :: max_text_width, total_text_width
        type(legend_box_t), intent(inout) :: box
        integer, intent(in) :: pixel_plot_width, pixel_plot_height
        
        real(wp) :: data_to_pixel_ratio_x, data_to_pixel_ratio_y
        integer :: max_text_height_pixels
        logical :: text_system_available
        
        ! Initialize text system for measurements
        text_system_available = init_text_system()
        
        ! Calculate data-to-pixel conversion ratio using actual plot-area pixels when available
        data_to_pixel_ratio_x = real(pixel_plot_width, wp) / data_width
        data_to_pixel_ratio_y = real(pixel_plot_height, wp) / data_height
        
        ! Measure text dimensions
        call measure_label_dimensions(labels, text_system_available, data_to_pixel_ratio_x, &
                                     data_width, max_text_width, total_text_width, &
                                     max_text_height_pixels)
        
        ! Calculate box dimensions based on measurements
        call set_legend_box_dimensions(box, max_text_width, max_text_height_pixels, &
                                      data_to_pixel_ratio_x, data_to_pixel_ratio_y, &
                                      size(labels))
    end subroutine calculate_optimal_legend_dimensions
    
    subroutine measure_label_dimensions(labels, text_system_available, data_to_pixel_ratio_x, &
                                       data_width, max_text_width, total_text_width, &
                                       max_text_height_pixels)
        !! Measure text dimensions for all labels
        character(len=*), intent(in) :: labels(:)
        logical, intent(in) :: text_system_available
        real(wp), intent(in) :: data_to_pixel_ratio_x, data_width
        real(wp), intent(out) :: max_text_width, total_text_width
        integer, intent(out) :: max_text_height_pixels
        
        integer :: i, text_height_pixels, plain_text_height
        real(wp) :: entry_text_width, text_width_pixels
        character(len=:), allocatable :: trimmed_label, processed_label
        character(len=512) :: temp_processed_label
        integer :: processed_len
        
        max_text_width = 0.0_wp
        total_text_width = 0.0_wp
        plain_text_height = legend_line_height()
        max_text_height_pixels = plain_text_height
        
        do i = 1, size(labels)
            trimmed_label = trim(labels(i))
            
            ! Process LaTeX to Unicode first for accurate width calculation
            call process_latex_in_text(trimmed_label, temp_processed_label, processed_len)
            processed_label = temp_processed_label(1:processed_len)
            
            if (text_system_available) then
                ! Calculate width of the processed text (after LaTeX conversion)
                text_width_pixels = real(calculate_text_width(trimmed_label), wp)
                text_height_pixels = plain_text_height
                if (has_mathtext(trimmed_label)) then
                    text_height_pixels = calculate_text_height(trimmed_label)
                end if
                max_text_height_pixels = max(max_text_height_pixels, text_height_pixels)
                entry_text_width = text_width_pixels / data_to_pixel_ratio_x
            else
                ! For fallback, use processed text length
                entry_text_width = real(len_trim(processed_label), wp) * data_width * TEXT_WIDTH_RATIO
            end if
            total_text_width = total_text_width + entry_text_width
            max_text_width = max(max_text_width, entry_text_width)
        end do
    end subroutine measure_label_dimensions
    
    subroutine set_legend_box_dimensions(box, max_text_width, max_text_height_pixels, &
                                        data_to_pixel_ratio_x, data_to_pixel_ratio_y, num_labels)
        !! Set legend box dimensions based on measurements
        type(legend_box_t), intent(inout) :: box
        real(wp), intent(in) :: max_text_width
        integer, intent(in) :: max_text_height_pixels
        real(wp), intent(in) :: data_to_pixel_ratio_x, data_to_pixel_ratio_y
        integer, intent(in) :: num_labels
        
        real(wp), parameter :: font_pixels = 10.0_wp*100.0_wp/72.0_wp

        ! Matplotlib rcParams express these lengths as multiples of font size.
        box%line_length = 2.0_wp*font_pixels/data_to_pixel_ratio_x
        box%text_spacing = 0.8_wp*font_pixels/data_to_pixel_ratio_x
        box%entry_height = real(max_text_height_pixels, wp)/data_to_pixel_ratio_y
        box%entry_spacing = 0.5_wp*font_pixels/data_to_pixel_ratio_y
        box%padding = 0.4_wp*font_pixels/data_to_pixel_ratio_y
        box%padding_x = 0.4_wp*font_pixels/data_to_pixel_ratio_x
        box%handle_height = 0.7_wp*font_pixels/data_to_pixel_ratio_y
        box%width = 2.0_wp*box%padding_x + box%line_length + &
                    box%text_spacing + max_text_width
        box%height = 2.0_wp*box%padding + &
                     real(num_labels, wp)*box%entry_height + &
                     real(num_labels - 1, wp)*box%entry_spacing
    end subroutine set_legend_box_dimensions
    
    function legend_line_height() result(height)
        !! Use ascender/descender ink bounds, as Matplotlib TextArea does for lp.
        integer :: height, x0, y0, x1, y1, top, bottom, i
        type(truetype_font_t) :: font
        real(wp) :: scale
        character(len=2), parameter :: sample = 'lp'

        height = 14
        if (.not. init_text_system()) return
        font = get_global_font()
        scale = get_font_scale()
        top = 0
        bottom = 0
        do i = 1, len(sample)
            call font%get_bitmap_box(iachar(sample(i:i)), scale, scale, &
                                     x0, y0, x1, y1)
            top = min(top, y0)
            bottom = max(bottom, y1)
        end do
        height = max(1, bottom - top)
    end function legend_line_height

    function get_actual_text_dimensions(label, data_to_pixel_x, data_to_pixel_y) result(dimensions)
        !! Get actual text dimensions using text system measurements  
        !! Returns [width, height] in data coordinates
        character(len=*), intent(in) :: label
        real(wp), intent(in) :: data_to_pixel_x, data_to_pixel_y
        real(wp) :: dimensions(2)  ! [width, height]
        integer :: width_pixels, height_pixels, processed_len
        logical :: text_system_available
        character(len=512) :: processed_label
        
        text_system_available = init_text_system()
        
        if (text_system_available) then
            ! Pass original text directly - calculate_text_width handles mathtext internally
            width_pixels = calculate_text_width(label)
            height_pixels = calculate_text_height(label)
            dimensions(1) = real(width_pixels, wp) / data_to_pixel_x
            dimensions(2) = real(height_pixels, wp) / data_to_pixel_y
        else
            ! Fallback estimation - process LaTeX for accurate character count
            call process_latex_in_text(label, processed_label, processed_len)
            dimensions(1) = real(processed_len, wp) * 8.0_wp / data_to_pixel_x  ! 8 pixels per char
            dimensions(2) = 16.0_wp / data_to_pixel_y  ! 16 pixels height
        end if
        
    end function get_actual_text_dimensions
    
    function get_legend_margins(data_width, data_height, &
                                pixel_plot_width, pixel_plot_height) result(margins)
        !! Inset between the legend box and the axes frame.
        !! Matches matplotlib's borderaxespad default (0.5 * 10pt font = 5pt),
        !! which at the standard ~100 dpi is ~7 px, independent of the data
        !! range. A fixed pixel inset keeps the legend flush in the corner the
        !! way matplotlib draws it, instead of a large data-proportional gap.
        real(wp), intent(in) :: data_width, data_height
        integer, intent(in), optional :: pixel_plot_width, pixel_plot_height
        real(wp) :: margins(2)  ! [x_margin, y_margin]
        real(wp), parameter :: BORDER_AXES_PAD_PX = 5.0_wp*100.0_wp/72.0_wp
        integer :: px_w, px_h

        if (present(pixel_plot_width) .and. present(pixel_plot_height)) then
            px_w = max(1, pixel_plot_width)
            px_h = max(1, pixel_plot_height)
            margins(1) = BORDER_AXES_PAD_PX * data_width / real(px_w, wp)
            margins(2) = BORDER_AXES_PAD_PX * data_height / real(px_h, wp)
        else
            margins(1) = data_width * 0.02_wp
            margins(2) = data_height * 0.02_wp
        end if

    end function get_legend_margins
    
    subroutine calculate_legend_position(box, data_width, data_height, position, margins)
        !! Calculate legend position based on placement preference
        !! Single Responsibility: Position calculation only
        type(legend_box_t), intent(inout) :: box
        real(wp), intent(in) :: data_width, data_height, margins(2)
        integer, intent(in) :: position

        select case (position)
        case (LEGEND_UPPER_LEFT)
            box%x = margins(1)
            box%y = data_height - margins(2)
        case (LEGEND_UPPER_RIGHT)
            box%x = data_width - box%width - margins(1)
            box%y = data_height - margins(2)
        case (LEGEND_LOWER_LEFT)
            box%x = margins(1)
            box%y = box%height + margins(2)
        case (LEGEND_LOWER_RIGHT)
            box%x = data_width - box%width - margins(1)
            box%y = box%height + margins(2)
        case (LEGEND_RIGHT, LEGEND_CENTER_RIGHT)
            box%x = data_width - box%width - margins(1)
            box%y = (data_height + box%height)*0.5_wp
        case (LEGEND_CENTER_LEFT)
            box%x = margins(1)
            box%y = (data_height + box%height)*0.5_wp
        case (LEGEND_LOWER_CENTER)
            box%x = (data_width - box%width)*0.5_wp
            box%y = box%height + margins(2)
        case (LEGEND_UPPER_CENTER)
            box%x = (data_width - box%width)*0.5_wp
            box%y = data_height - margins(2)
        case (LEGEND_CENTER)
            box%x = (data_width - box%width)*0.5_wp
            box%y = (data_height + box%height)*0.5_wp
        case (LEGEND_EAST)
            box%x = data_width + margins(1)
            box%y = (data_height + box%height)*0.5_wp
        case default  ! Default to upper right
            box%x = data_width - box%width - margins(1)
            box%y = data_height - margins(2)
        end select
        
    end subroutine calculate_legend_position

end module fortplot_legend_layout