fortplot_ticks.f90 Source File


Source Code

module fortplot_ticks
    !! Tick generation and formatting for all scale types
    !! 
    !! This module handles all tick-related functionality including
    !! linear, logarithmic, and symmetric logarithmic tick generation,
    !! following Single Responsibility Principle by focusing solely
    !! on tick calculation and formatting.
    !! 
    !! Author: fortplot contributors
    
    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_constants, only: SCIENTIFIC_THRESHOLD_HIGH
    implicit none
    
    intrinsic :: floor, log10
    
    private
    public :: calculate_tick_labels, calculate_tick_labels_log, calculate_tick_labels_symlog
    public :: format_tick_value, calculate_nice_axis_limits
    public :: generate_scale_aware_tick_labels, format_tick_value_smart, format_log_tick_value
    public :: find_nice_tick_locations, 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

    function format_tick_value(value, range) result(formatted)
        !! Format tick value based on data range like matplotlib
        real(wp), intent(in) :: value, range
        character(len=20) :: formatted
        real(wp) :: abs_value
        
        abs_value = abs(value)
        
        if (abs_value < 1.0e-10_wp) then
            formatted = '0'
        else if (range >= 100.0_wp .or. abs_value >= SCIENTIFIC_THRESHOLD_HIGH) then
            ! Use integer format for large ranges
            write(formatted, '(I0)') nint(value)
        else if (range >= 10.0_wp .or. abs_value >= 10.0_wp) then
            ! 1 decimal place for medium ranges
            write(formatted, '(F0.1)') value
            call ensure_leading_zero(formatted)
            call remove_trailing_zeros(formatted)
        else if (range >= 1.0_wp .or. abs_value >= 1.0_wp) then
            ! 2 decimal places for small ranges
            write(formatted, '(F0.2)') value
            call ensure_leading_zero(formatted)
            call remove_trailing_zeros(formatted)
        else
            ! 3 decimal places for very small ranges
            write(formatted, '(F0.3)') value
            call ensure_leading_zero(formatted)
            call remove_trailing_zeros(formatted)
        end if
    end function format_tick_value

    subroutine calculate_tick_labels_log(data_min, data_max, num_ticks, labels)
        !! Calculate logarithmic tick labels at powers of 10 with sub-ticks
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        call generate_log_tick_locations(data_min, data_max, num_ticks, labels)
    end subroutine calculate_tick_labels_log

    subroutine calculate_tick_labels_symlog(data_min, data_max, linear_threshold, num_ticks, labels)
        !! Calculate symlog tick labels with improved linear and log regions
        real(wp), intent(in) :: data_min, data_max, linear_threshold
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        call generate_symlog_tick_locations(data_min, data_max, linear_threshold, &
                                           num_ticks, labels)
    end subroutine calculate_tick_labels_symlog

    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

    ! === PRIVATE HELPER ROUTINES ===
    
    subroutine remove_trailing_zeros(str)
        !! Remove trailing zeros from decimal representation
        character(len=*), intent(inout) :: str
        integer :: i, decimal_pos
        
        decimal_pos = index(str, '.')
        if (decimal_pos == 0) return  ! No decimal point
        
        ! Remove trailing zeros
        do i = len_trim(str), decimal_pos + 1, -1
            if (str(i:i) == '0') then
                str(i:i) = ' '
            else
                exit
            end if
        end do
        
        ! Remove trailing decimal point if all decimals were zeros
        if (len_trim(str) == decimal_pos) then
            str(decimal_pos:decimal_pos) = ' '
        end if
    end subroutine remove_trailing_zeros

    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


    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



    subroutine generate_log_tick_locations(data_min, data_max, num_ticks, labels)
        !! Generate proper logarithmic tick locations with sub-ticks when appropriate
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        real(wp) :: decade_range
        logical :: use_subticks
        
        if (num_ticks <= 0 .or. data_min <= 0.0_wp .or. data_max <= 0.0_wp) then
            labels(1:min(num_ticks, size(labels))) = ''
            return
        end if
        
        ! Calculate decade range to determine tick strategy
        decade_range = log10(data_max) - log10(data_min)
        use_subticks = decade_range <= 2.0_wp  ! Use subticks for small ranges
        
        if (use_subticks) then
            call generate_log_subticks(data_min, data_max, num_ticks, labels)
        else
            call generate_log_major_ticks(data_min, data_max, num_ticks, labels)
        end if
    end subroutine generate_log_tick_locations
    
    subroutine generate_log_major_ticks(data_min, data_max, num_ticks, labels)
        !! Generate major ticks at powers of 10 for wide log ranges
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        integer :: i, min_power, max_power, actual_num_ticks, power
        real(wp) :: tick_value
        
        min_power = floor(log10(data_min))
        max_power = ceiling(log10(data_max))
        
        actual_num_ticks = 0
        do power = min_power, max_power
            if (actual_num_ticks >= size(labels)) exit
            
            tick_value = 10.0_wp ** power
            
            ! Use tolerance for endpoint inclusion like matplotlib
            if (tick_value >= data_min * 0.999_wp .and. tick_value <= data_max * 1.001_wp) then
                actual_num_ticks = actual_num_ticks + 1
                labels(actual_num_ticks) = format_log_tick_value(tick_value)
            end if
        end do
        
        ! Clear unused labels
        do i = actual_num_ticks + 1, min(num_ticks, size(labels))
            labels(i) = ''
        end do
    end subroutine generate_log_major_ticks
    
    subroutine generate_log_subticks(data_min, data_max, num_ticks, labels)
        !! Generate subticks within decades for narrow log ranges
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        integer :: i, actual_num_ticks, subtick_idx, power
        real(wp) :: tick_value, decade_start
        real(wp), parameter :: subtick_multipliers(8) = [1.0_wp, 2.0_wp, 3.0_wp, 5.0_wp, &
                                                         6.0_wp, 7.0_wp, 8.0_wp, 9.0_wp]
        
        actual_num_ticks = 0
        power = floor(log10(data_min))
        
        do while (power <= ceiling(log10(data_max)) .and. actual_num_ticks < size(labels))
            decade_start = 10.0_wp ** power
            
            ! Add major tick (10^power)
            tick_value = decade_start
            if (tick_value >= data_min * 0.999_wp .and. tick_value <= data_max * 1.001_wp) then
                actual_num_ticks = actual_num_ticks + 1
                labels(actual_num_ticks) = format_log_tick_value(tick_value)
            end if
            
            ! Add subticks within this decade
            do subtick_idx = 1, size(subtick_multipliers)
                if (actual_num_ticks >= size(labels)) exit
                
                tick_value = decade_start * subtick_multipliers(subtick_idx)
                
                if (tick_value >= data_min * 0.999_wp .and. tick_value <= data_max * 1.001_wp .and. &
                    tick_value < 10.0_wp ** (power + 1)) then
                    actual_num_ticks = actual_num_ticks + 1
                    labels(actual_num_ticks) = format_log_tick_value(tick_value)
                end if
            end do
            
            power = power + 1
        end do
        
        ! Clear unused labels
        do i = actual_num_ticks + 1, min(num_ticks, size(labels))
            labels(i) = ''
        end do
    end subroutine generate_log_subticks
    
    subroutine generate_symlog_tick_locations(data_min, data_max, linear_threshold, &
                                             num_ticks, labels)
        !! Generate improved symlog tick locations
        real(wp), intent(in) :: data_min, data_max, linear_threshold
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        
        integer :: actual_num_ticks, i
        real(wp) :: tick_locations(20)
        
        if (num_ticks <= 0) then
            labels(1:min(num_ticks, size(labels))) = ''
            return
        end if
        
        call calculate_symlog_ticks(data_min, data_max, linear_threshold, &
                                   tick_locations, actual_num_ticks)
        
        ! Format the tick locations using scale-aware formatting
        do i = 1, min(actual_num_ticks, size(labels))
            if (abs(tick_locations(i)) > linear_threshold) then
                ! Use log formatting for values outside linear region
                labels(i) = format_log_tick_value(tick_locations(i))
            else
                ! Use regular formatting for linear region
                labels(i) = format_tick_value(tick_locations(i), data_max - data_min)
            end if
        end do
        
        ! Clear unused labels
        do i = actual_num_ticks + 1, min(num_ticks, size(labels))
            labels(i) = ''
        end do
    end subroutine generate_symlog_tick_locations
    
    subroutine calculate_symlog_ticks(data_min, data_max, linear_threshold, &
                                     tick_locations, actual_num_ticks)
        !! Calculate symlog tick locations with proper linear/log transitions
        real(wp), intent(in) :: data_min, data_max, linear_threshold
        real(wp), intent(out) :: tick_locations(:)
        integer, intent(out) :: actual_num_ticks
        real(wp) :: candidates(20)
        integer :: num_candidates
        
        actual_num_ticks = 0
        num_candidates = 0
        
        ! Always include zero if it's in range
        if (data_min <= 0.0_wp .and. data_max >= 0.0_wp) then
            num_candidates = num_candidates + 1
            candidates(num_candidates) = 0.0_wp
        end if
        
        ! Add positive log ticks
        if (data_max > linear_threshold) then
            call add_positive_log_candidates(data_max, linear_threshold, candidates, num_candidates)
        end if
        
        ! Add negative log ticks
        if (data_min < -linear_threshold) then
            call add_negative_log_candidates(data_min, linear_threshold, candidates, num_candidates)
        end if
        
        ! Add linear region ticks
        call add_linear_region_candidates(data_min, data_max, linear_threshold, candidates, num_candidates)
        
        ! Sort and filter candidates within range
        call sort_and_filter_candidates(candidates, num_candidates, data_min, data_max, &
                                       tick_locations, actual_num_ticks)
    end subroutine calculate_symlog_ticks
    
    subroutine add_positive_log_candidates(data_max, linear_threshold, candidates, num_candidates)
        !! Add positive logarithmic tick candidates
        real(wp), intent(in) :: data_max, linear_threshold
        real(wp), intent(inout) :: candidates(:)
        integer, intent(inout) :: num_candidates
        
        integer :: power
        real(wp) :: tick_val
        
        power = 1
        do while (power <= 10 .and. num_candidates < size(candidates))
            tick_val = 10.0_wp ** power
            if (tick_val > linear_threshold .and. tick_val <= data_max * 1.001_wp) then
                num_candidates = num_candidates + 1
                candidates(num_candidates) = tick_val
            end if
            power = power + 1
        end do
    end subroutine add_positive_log_candidates
    
    subroutine add_negative_log_candidates(data_min, linear_threshold, candidates, num_candidates)
        !! Add negative logarithmic tick candidates
        real(wp), intent(in) :: data_min, linear_threshold
        real(wp), intent(inout) :: candidates(:)
        integer, intent(inout) :: num_candidates
        
        integer :: power
        real(wp) :: tick_val
        
        power = 1
        do while (power <= 10 .and. num_candidates < size(candidates))
            tick_val = -(10.0_wp ** power)
            if (tick_val < -linear_threshold .and. tick_val >= data_min * 1.001_wp) then
                num_candidates = num_candidates + 1
                candidates(num_candidates) = tick_val
            end if
            power = power + 1
        end do
    end subroutine add_negative_log_candidates
    
    subroutine add_linear_region_candidates(data_min, data_max, linear_threshold, candidates, num_candidates)
        !! Add linear region tick candidates
        real(wp), intent(in) :: data_min, data_max, linear_threshold
        real(wp), intent(inout) :: candidates(:)
        integer, intent(inout) :: num_candidates
        
        real(wp) :: linear_min, linear_max, step, current_tick
        integer :: i, num_linear_ticks
        
        linear_min = max(data_min, -linear_threshold)
        linear_max = min(data_max, linear_threshold)
        
        if (linear_max > linear_min .and. num_candidates < size(candidates)) then
            num_linear_ticks = 3  ! Simple linear ticks in the middle region
            step = (linear_max - linear_min) / real(num_linear_ticks + 1, wp)
            
            do i = 1, num_linear_ticks
                current_tick = linear_min + real(i, wp) * step
                if (abs(current_tick) > 1.0e-10_wp .and. num_candidates < size(candidates)) then
                    num_candidates = num_candidates + 1
                    candidates(num_candidates) = current_tick
                end if
            end do
        end if
    end subroutine add_linear_region_candidates
    
    subroutine sort_and_filter_candidates(candidates, num_candidates, data_min, data_max, &
                                         tick_locations, actual_num_ticks)
        !! Sort candidates and filter to final tick locations
        real(wp), intent(in) :: candidates(:)
        integer, intent(in) :: num_candidates
        real(wp), intent(in) :: data_min, data_max
        real(wp), intent(out) :: tick_locations(:)
        integer, intent(out) :: actual_num_ticks
        
        integer :: i
        real(wp) :: temp_candidates(20)
        
        ! Copy and simple sort (bubble sort for small arrays)
        temp_candidates(1:num_candidates) = candidates(1:num_candidates)
        call simple_sort(temp_candidates, num_candidates)
        
        ! Filter within range
        actual_num_ticks = 0
        do i = 1, num_candidates
            if (temp_candidates(i) >= data_min .and. temp_candidates(i) <= data_max) then
                if (actual_num_ticks < size(tick_locations)) then
                    actual_num_ticks = actual_num_ticks + 1
                    tick_locations(actual_num_ticks) = temp_candidates(i)
                end if
            end if
        end do
    end subroutine sort_and_filter_candidates
    
    subroutine simple_sort(array, n)
        !! Simple bubble sort for small arrays
        real(wp), intent(inout) :: array(:)
        integer, intent(in) :: n
        
        integer :: i, j
        real(wp) :: temp
        
        do i = 1, n - 1
            do j = 1, n - i
                if (array(j) > array(j + 1)) then
                    temp = array(j)
                    array(j) = array(j + 1)
                    array(j + 1) = temp
                end if
            end do
        end do
    end subroutine simple_sort

    subroutine generate_scale_aware_tick_labels(data_min, data_max, num_ticks, labels, scale, threshold)
        !! Generate tick labels based on scale type - common function for all backends
        real(wp), intent(in) :: data_min, data_max
        integer, intent(in) :: num_ticks
        character(len=20), intent(out) :: labels(:)
        character(len=*), intent(in), optional :: scale
        real(wp), intent(in), optional :: threshold
        
        character(len=10) :: scale_type
        real(wp) :: symlog_threshold
        
        ! Set defaults
        scale_type = 'linear'
        if (present(scale)) scale_type = scale
        symlog_threshold = 1.0_wp
        if (present(threshold)) symlog_threshold = threshold
        
        ! Generate labels based on scale type
        select case (trim(scale_type))
        case ('log')
            call calculate_tick_labels_log(data_min, data_max, num_ticks, labels)
        case ('symlog')
            call calculate_tick_labels_symlog(data_min, data_max, symlog_threshold, num_ticks, labels)
        case default  ! 'linear'
            call calculate_tick_labels(data_min, data_max, num_ticks, labels)
        end select
    end subroutine generate_scale_aware_tick_labels

    function format_tick_value_smart(value, max_chars) result(formatted)
        !! Smart tick value formatting with automatic exponential notation for long labels
        !! Limits output to max_chars and uses exponential notation when needed
        real(wp), intent(in) :: value
        integer, intent(in) :: max_chars
        character(len=20) :: formatted
        real(wp) :: abs_value
        
        abs_value = abs(value)
        
        ! Handle zero
        if (abs_value < 1.0e-10_wp) then
            formatted = '0'
            return
        end if
        
        ! Try normal formatting first
        if (abs_value >= SCIENTIFIC_THRESHOLD_HIGH .or. abs_value < 0.001_wp) then
            ! Large or very small numbers - use exponential notation
            write(formatted, '(ES8.1)') value
        else if (abs_value >= 100.0_wp) then
            ! Integer format for hundreds
            write(formatted, '(I0)') nint(value)
        else if (abs_value >= 10.0_wp) then
            ! 1 decimal place
            write(formatted, '(F0.1)') value
            call remove_trailing_zeros(formatted)
        else if (abs_value >= 1.0_wp) then
            ! 2 decimal places
            write(formatted, '(F0.2)') value
            call remove_trailing_zeros(formatted)
        else
            ! Small numbers - up to 3 decimal places
            write(formatted, '(F0.3)') value
            call remove_trailing_zeros(formatted)
        end if
        
        ! If formatted string is too long, use exponential notation
        if (len_trim(formatted) > max_chars) then
            if (max_chars >= 7) then
                write(formatted, '(ES7.0)') value
            else
                write(formatted, '(ES6.0)') value
            end if
        end if
        
        call ensure_leading_zero(formatted)
    end function format_tick_value_smart

    function format_log_tick_value(value) result(formatted)
        !! Format logarithmic tick values using scientific notation like matplotlib
        real(wp), intent(in) :: value
        character(len=20) :: formatted
        real(wp) :: log_val
        integer :: exponent
        logical :: is_power_of_ten
        
        if (abs(value) < 1.0e-10_wp) then
            formatted = '0'
            return
        end if
        
        ! Check if this is a power of 10
        log_val = log10(abs(value))
        exponent = nint(log_val)
        is_power_of_ten = abs(log_val - real(exponent, wp)) < 1.0e-10_wp
        
        if (is_power_of_ten) then
            ! Format common powers of 10 as decimal for readability
            if (value < 0.0_wp) then
                if (exponent >= -3 .and. exponent <= 3) then
                    ! Use decimal format for common negative powers
                    write(formatted, '(F0.0)') value
                else
                    write(formatted, '(A, I0)') '-10^', exponent
                end if
            else if (exponent == 0) then
                formatted = '1'
            else if (exponent == 1) then
                formatted = '10'
            else if (exponent == 2) then
                formatted = '100'
            else if (exponent == 3) then
                formatted = '1000'
            else if (exponent == -1) then
                formatted = '0.1'
            else if (exponent == -2) then
                formatted = '0.01'
            else if (exponent == -3) then
                formatted = '0.001'
            else
                write(formatted, '(A, I0)') '10^', exponent
            end if
        else
            ! For non-powers of 10, use regular formatting
            if (abs(value) >= SCIENTIFIC_THRESHOLD_HIGH .or. abs(value) < 0.01_wp) then
                write(formatted, '(ES8.1)') value
            else
                formatted = format_tick_value(value, abs(value))
            end if
        end if
    end function format_log_tick_value

    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

end module fortplot_ticks