fortplot_tick_calculation.f90 Source File


Source Code

module fortplot_tick_calculation
    !! Core tick calculation algorithms for linear scales
    !! 
    !! Provides:
    !! - Nice tick location algorithms following matplotlib MaxNLocator
    !! - Axis limit calculation with nice boundaries
    !! - Linear scale tick generation
    
    use, intrinsic :: iso_fortran_env, only: wp => real64
    implicit none
    
    intrinsic :: floor, log10
    
    private
    public :: calculate_tick_labels, calculate_nice_axis_limits
    public :: find_nice_tick_locations, determine_decimal_places_from_step
    public :: determine_decimals_from_ticks
    public :: format_tick_value_consistent

contains

    subroutine calculate_tick_labels(data_min, data_max, num_ticks, labels)
        !! Calculate appropriate tick labels at nice locations like matplotlib
        !! Ensures all labels have consistent formatting and nice round numbers
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        integer :: i, decimal_places, actual_num_ticks
        real(wp) :: nice_min, nice_max, nice_step
        real(wp) :: tick_locations(size(labels))
        
        if (num_ticks <= 1) return
        
        ! Find nice tick locations using matplotlib-style algorithm
        call find_nice_tick_locations(data_min, data_max, num_ticks, &
                                     nice_min, nice_max, nice_step, &
                                     tick_locations, actual_num_ticks)
        
        ! Determine consistent formatting for the nice step size
        decimal_places = determine_decimal_places_from_step(nice_step)
        
        ! Format the nice tick locations with consistent decimal places
        do i = 1, min(actual_num_ticks, size(labels))
            labels(i) = format_tick_value_consistent(tick_locations(i), decimal_places)
        end do
        
        ! Clear unused labels
        do i = actual_num_ticks + 1, size(labels)
            labels(i) = ''
        end do
    end subroutine calculate_tick_labels

    subroutine calculate_nice_axis_limits(data_min, data_max, target_num_ticks, &
                                         nice_min, nice_max)
        !! Calculate nice axis limits that encompass the data like matplotlib
        !! The axis limits are set to nice round numbers based on tick locations
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: target_num_ticks
        real(wp), intent(out) :: nice_min, nice_max
        
        real(wp) :: nice_step, tick_locations(20)
        integer :: actual_num_ticks
        
        ! Use the same algorithm as tick calculation to find nice boundaries
        call find_nice_tick_locations(data_min, data_max, target_num_ticks, &
                                     nice_min, nice_max, nice_step, &
                                     tick_locations, actual_num_ticks)
        
        ! The nice_min and nice_max from find_nice_tick_locations are already
        ! appropriate axis limits that encompass the data
    end subroutine calculate_nice_axis_limits

    subroutine find_nice_tick_locations(data_min, data_max, target_num_ticks, &
                                       nice_min, nice_max, nice_step, &
                                       tick_locations, actual_num_ticks)
        !! Find nice tick locations following matplotlib's MaxNLocator algorithm exactly
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: target_num_ticks
        real(wp), intent(out) :: nice_min, nice_max, nice_step
        real(wp), intent(out) :: tick_locations(:)
        integer, intent(out) :: actual_num_ticks
        
        real(wp) :: range, rough_step, magnitude, normalized_step
        real(wp) :: nice_normalized_step, best_vmin
        integer :: low_edge, high_edge, i
        
        range = data_max - data_min
        if (range <= 0.0_wp) then
            tick_locations(1) = data_min
            actual_num_ticks = 1
            return
        end if
        
        ! Calculate rough step size
        rough_step = range / real(max(target_num_ticks - 1, 1), wp)
        
        ! Find magnitude and normalize
        magnitude = 10.0_wp ** floor(log10(rough_step))
        normalized_step = rough_step / magnitude
        
        ! Choose nice normalized step (1, 2, 5, 10)
        if (normalized_step <= 1.0_wp) then
            nice_normalized_step = 1.0_wp
        else if (normalized_step <= 2.0_wp) then
            nice_normalized_step = 2.0_wp
        else if (normalized_step <= 5.0_wp) then
            nice_normalized_step = 5.0_wp
        else
            nice_normalized_step = 10.0_wp
        end if
        
        nice_step = nice_normalized_step * magnitude
        
        ! Follow matplotlib's exact algorithm:
        ! best_vmin = (data_min // step) * step
        best_vmin = floor(data_min / nice_step) * nice_step
        
        ! Calculate edge indices like matplotlib's _Edge_integer with proper tolerance
        ! low = largest n where n*step <= (data_min - best_vmin)
        low_edge = floor((data_min - best_vmin) / nice_step + 1.0e-10_wp)
        
        ! high = smallest n where n*step >= (data_max - best_vmin)  
        ! Add small epsilon to ensure floating point precision doesn't miss endpoints
        high_edge = floor((data_max - best_vmin) / nice_step + 1.0_wp - 1.0e-10_wp)
        
        ! Generate ticks: np.arange(low, high + 1) * step + best_vmin
        ! Equivalent to matplotlib's high + 1 endpoint inclusion
        actual_num_ticks = 0
        do i = low_edge, high_edge
            if (actual_num_ticks >= size(tick_locations)) exit
            actual_num_ticks = actual_num_ticks + 1
            tick_locations(actual_num_ticks) = real(i, wp) * nice_step + best_vmin
        end do
        
        ! Set nice boundaries for axis limits
        if (actual_num_ticks > 0) then
            nice_min = tick_locations(1)
            nice_max = tick_locations(actual_num_ticks)
        else
            ! No ticks generated - use data bounds as fallback
            nice_min = data_min
            nice_max = data_max
        end if
    end subroutine find_nice_tick_locations

    function determine_decimal_places_from_step(step) result(decimal_places)
        !! Determine decimal places based on step size for nice formatting
        real(wp), intent(in) :: step
        integer :: decimal_places
        
        if (step >= 1.0_wp) then
            decimal_places = 0
        else if (step >= 0.1_wp) then
            decimal_places = 1
        else if (step >= 0.01_wp) then
            decimal_places = 2
        else if (step >= 0.001_wp) then
            decimal_places = 3
        else
            decimal_places = 4
        end if
    end function determine_decimal_places_from_step

    function determine_decimals_from_ticks(tick_positions, n) result(decimal_places)
        !! Determine decimal places from an array of tick positions.
        !! Uses the smallest non-zero spacing as representative step.
        real(wp), intent(in) :: tick_positions(:)
        integer, intent(in) :: n
        integer :: decimal_places
        real(wp) :: step, d
        integer :: i

        decimal_places = 0
        if (n < 2) return

        step = abs(tick_positions(2) - tick_positions(1))
        do i = 3, n
            d = abs(tick_positions(i) - tick_positions(i-1))
            if (d > 1.0e-12_wp) step = min(step, d)
        end do

        decimal_places = determine_decimal_places_from_step(step)
    end function determine_decimals_from_ticks

    function format_tick_value_consistent(value, decimal_places) result(formatted)
        !! Format tick value with consistent decimal places for uniform appearance
        real(wp), intent(in) :: value
        integer, intent(in) :: decimal_places
        character(len=20) :: formatted
        character(len=10) :: format_str
        
        if (abs(value) < 1.0e-10_wp) then
            if (decimal_places == 0) then
                formatted = '0'
            else
                write(format_str, '(A, I0, A)') '(F0.', decimal_places, ')'
                write(formatted, format_str) 0.0_wp
            end if
        else if (decimal_places == 0) then
            write(formatted, '(I0)') nint(value)
        else
            write(format_str, '(A, I0, A)') '(F0.', decimal_places, ')'
            write(formatted, format_str) value
        end if
        
        call ensure_leading_zero(formatted)
    end function format_tick_value_consistent

    subroutine ensure_leading_zero(str)
        !! Ensure numbers like .5 become 0.5 for readability
        character(len=*), intent(inout) :: str
        character(len=len(str)) :: temp
        
        str = adjustl(str)  ! Remove leading spaces
        if (len_trim(str) > 0) then
            if (str(1:1) == '.') then
                temp = '0' // trim(str)
                str = temp
            else if (str(1:2) == '-.') then
                temp = '-0' // str(2:len_trim(str))
                str = temp
            end if
        end if
    end subroutine ensure_leading_zero

end module fortplot_tick_calculation