fortplot_axes.f90 Source File


Source Code

module fortplot_axes
    !! Axes and tick generation module
    !!
    !! This module handles axis drawing, tick computation, and label formatting
    !! for all scale types. Date-specific logic is delegated to fortplot_axes_date.

    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_context
    use fortplot_scales
    use fortplot_constants, only: SCIENTIFIC_THRESHOLD_HIGH
    use fortplot_tick_formatting, only: format_power_of_ten_label, &
                                        format_log_mantissa_label
    use fortplot_axes_date, only: is_date_scale, format_date_tick_label, &
                                  default_date_format, date_value_to_unix_seconds, &
                                  compute_date_ticks, pick_fixed_step_seconds
    implicit none

    private
    public :: compute_scale_ticks, format_tick_label, pick_fixed_step_seconds, MAX_TICKS

    integer, parameter :: MAX_TICKS = 20

    ! Format threshold constants for tick label formatting
    ! SCIENTIFIC_THRESHOLD_HIGH is imported from fortplot_constants
    real(wp), parameter :: SCIENTIFIC_THRESHOLD_LOW = 0.01_wp
    ! Use scientific for abs(value) < this
    real(wp), parameter :: NO_DECIMAL_THRESHOLD = 100.0_wp
    ! No decimal places for abs(value) >= this
    real(wp), parameter :: ONE_DECIMAL_THRESHOLD = 10.0_wp
    ! One decimal place for abs(value) >= this
    real(wp), parameter :: TWO_DECIMAL_THRESHOLD = 1.0_wp
    ! Two decimal places for abs(value) >= this
    real(wp), parameter :: TICK_EPS = 1.0e-10_wp
    ! Numerical tolerance for tick comparisons

contains

    subroutine compute_scale_ticks(scale_type, data_min, data_max, threshold, &
                                   tick_positions, num_ticks, &
                                   step_min, step_max)
        !! Compute tick positions for different scale types
        !!
        !! @param scale_type: Type of scale ('linear', 'log', 'symlog')
        !! @param data_min: Lower edge of the interval ticks span (for the
        !!                  rendered axis this is the margin-expanded view edge).
        !! @param data_max: Upper edge of the interval ticks span.
        !! @param threshold: Threshold for symlog (ignored for others)
        !! @param tick_positions: Output array of tick positions
        !! @param num_ticks: Number of ticks generated
        !! @param step_min: Lower edge of the raw data range used to pick the
        !!                  linear nice step. When absent, [data_min, data_max]
        !!                  is used (the historical behaviour). Passing the raw
        !!                  data range here keeps the step matplotlib-correct
        !!                  while ticks still cover the expanded view.
        !! @param step_max: Upper edge of the raw data range (see step_min).

        character(len=*), intent(in) :: scale_type
        real(wp), intent(in) :: data_min, data_max, threshold
        real(wp), intent(out) :: tick_positions(MAX_TICKS)
        integer, intent(out) :: num_ticks
        real(wp), intent(in), optional :: step_min, step_max

        select case (trim(scale_type))
        case ('linear')
            call compute_linear_ticks(data_min, data_max, tick_positions, num_ticks, &
                                      step_min, step_max)
        case ('log')
            call compute_log_ticks(data_min, data_max, tick_positions, num_ticks)
        case ('symlog')
            call compute_symlog_ticks(data_min, data_max, threshold, &
                                      tick_positions, num_ticks)
        case ('date', 'date_jd')
            call compute_date_ticks(scale_type, data_min, data_max, &
                                    tick_positions, num_ticks)
        case default
            call compute_linear_ticks(data_min, data_max, tick_positions, num_ticks)
        end select
    end subroutine compute_scale_ticks

    subroutine compute_linear_ticks(view_min, view_max, tick_positions, num_ticks, &
                                    step_min, step_max)
        !! Compute tick positions for linear scale.
        !!
        !! Ticks are emitted across [view_min, view_max], the interval the axis
        !! actually renders (data range plus the 5% per-side margin, sticky
        !! edges already folded in by the caller), so nice-step multiples that
        !! land in the margin appear, matching matplotlib. The nice step is
        !! selected from [step_min, step_max] (the raw data range) when given,
        !! otherwise from the view interval. Deriving the step from the data
        !! range keeps it matplotlib-correct even though coverage is wider.
        real(wp), intent(in) :: view_min, view_max
        real(wp), intent(out) :: tick_positions(MAX_TICKS)
        integer, intent(out) :: num_ticks
        real(wp), intent(in), optional :: step_min, step_max

        real(wp) :: step_range, view_range, step, nice_step, tick_value, hi_eps
        integer :: max_ticks_desired

        max_ticks_desired = 9
        view_range = view_max - view_min
        step_range = view_range
        if (present(step_min) .and. present(step_max)) step_range = step_max - step_min

        if (view_range <= 0.0_wp .or. step_range <= 0.0_wp) then
            num_ticks = 0
            return
        end if

        step = step_range/real(max_ticks_desired, wp)
        nice_step = calculate_nice_step(step)

        ! Walk nice-step multiples across the view interval, not just the data
        ! range, so edge ticks inside the margin appear (matplotlib behaviour).
        hi_eps = TICK_EPS*max(1.0_wp, abs(view_max))
        tick_value = ceiling(view_min/nice_step - TICK_EPS)*nice_step
        num_ticks = 0

        do while (tick_value <= view_max + hi_eps .and. num_ticks < MAX_TICKS)
            num_ticks = num_ticks + 1
            tick_positions(num_ticks) = tick_value
            tick_value = tick_value + nice_step
        end do
    end subroutine compute_linear_ticks

    subroutine compute_log_ticks(data_min, data_max, tick_positions, num_ticks)
        !! Compute tick positions for logarithmic scale.
        !!
        !! Matches matplotlib's LogLocator: when the visible range spans at least
        !! one full decade, the decade powers (10**p) are the ticks. For a
        !! sub-decade range like [42, 50] no power of ten lands inside, so
        !! matplotlib promotes mantissa subdivisions to labeled ticks. We mirror
        !! that by trying progressively finer mantissa sets {1..9} then 0.1 steps
        !! and keeping the coarsest set that yields enough ticks.
        real(wp), intent(in) :: data_min, data_max
        real(wp), intent(out) :: tick_positions(MAX_TICKS)
        integer, intent(out) :: num_ticks

        integer, parameter :: MIN_SUBDECADE_TICKS = 2
        real(wp) :: trial_positions(MAX_TICKS)
        real(wp) :: sub_min
        integer :: num_decade_ticks, trial_count

        if (data_min <= 0.0_wp .or. data_max <= 0.0_wp) then
            num_ticks = 0
            return
        end if

        call collect_log_mantissa_ticks(data_min, data_max, 1.0_wp, &
                                        tick_positions, num_decade_ticks)
        num_ticks = num_decade_ticks
        if (num_decade_ticks >= MIN_SUBDECADE_TICKS) return

        ! Too few decade ticks. Promote mantissa subdivisions to labeled ticks,
        ! matching matplotlib's sub-decade log labels. When a decade power is in
        ! range, only subdivide from that power upward (matplotlib does not label
        ! the partial decade below the lowest visible power); otherwise subdivide
        ! the data's own decade so a sub-decade range like [42, 50] is covered.
        if (num_decade_ticks == 1) then
            sub_min = tick_positions(1)
        else
            sub_min = data_min
        end if

        ! Try integer mantissa subdivisions {1..9} first, then 0.1 steps.
        call collect_log_mantissa_ticks(sub_min, data_max, 0.0_wp, &
                                        trial_positions, trial_count)
        if (trial_count >= MIN_SUBDECADE_TICKS .and. trial_count <= MAX_TICKS) then
            tick_positions = trial_positions
            num_ticks = trial_count
            return
        end if

        call collect_log_mantissa_ticks(sub_min, data_max, 0.1_wp, &
                                        trial_positions, trial_count)
        if (trial_count >= MIN_SUBDECADE_TICKS) then
            tick_positions = trial_positions
            num_ticks = trial_count
        end if
    end subroutine compute_log_ticks

    subroutine collect_log_mantissa_ticks(data_min, data_max, mantissa_step, &
                                          tick_positions, num_ticks)
        !! Collect log-scale tick positions in [data_min, data_max].
        !! mantissa_step controls the subdivision within each decade:
        !!   1.0 -> decade powers only (10**p)
        !!   0.0 -> integer mantissas {1,2,...,9} * 10**p
        !!   0.1 -> tenth mantissas    {1.0,1.1,...,9.9} * 10**p
        real(wp), intent(in) :: data_min, data_max, mantissa_step
        real(wp), intent(out) :: tick_positions(MAX_TICKS)
        integer, intent(out) :: num_ticks

        real(wp) :: value, mantissa, step
        integer :: start_power, end_power, power, m, m_max
        real(wp) :: lo_eps, hi_eps

        num_ticks = 0
        start_power = floor(log10(data_min))
        end_power = ceiling(log10(data_max))
        lo_eps = data_min*(1.0_wp - TICK_EPS)
        hi_eps = data_max*(1.0_wp + TICK_EPS)

        if (mantissa_step >= 1.0_wp) then
            do power = start_power, end_power
                if (num_ticks >= MAX_TICKS) exit
                value = 10.0_wp**power
                if (value >= lo_eps .and. value <= hi_eps) then
                    num_ticks = num_ticks + 1
                    tick_positions(num_ticks) = value
                end if
            end do
            return
        end if

        if (mantissa_step <= 0.0_wp) then
            step = 1.0_wp
            m_max = 9
        else
            step = mantissa_step
            m_max = nint(9.0_wp/mantissa_step) + 9
        end if

        do power = start_power, end_power
            do m = 1, m_max
                if (num_ticks >= MAX_TICKS) exit
                mantissa = 1.0_wp + real(m - 1, wp)*step
                if (mantissa >= 10.0_wp) exit
                value = mantissa*10.0_wp**power
                if (value >= lo_eps .and. value <= hi_eps) then
                    num_ticks = num_ticks + 1
                    tick_positions(num_ticks) = value
                end if
            end do
            if (num_ticks >= MAX_TICKS) exit
        end do
    end subroutine collect_log_mantissa_ticks

    subroutine compute_symlog_ticks(data_min, data_max, threshold, &
                                    tick_positions, num_ticks)
        !! Compute tick positions for symmetric logarithmic scale
        real(wp), intent(in) :: data_min, data_max, threshold
        real(wp), intent(out) :: tick_positions(MAX_TICKS)
        integer, intent(out) :: num_ticks

        num_ticks = 0

        ! Add negative log region ticks
        if (data_min < -threshold) then
            call add_negative_symlog_ticks(data_min, min(-threshold, data_max), &
                                           tick_positions, num_ticks)
        end if

        ! Add linear region ticks (only for the region within threshold bounds)
        if (max(data_min, -threshold) <= min(data_max, threshold)) then
            call add_linear_symlog_ticks(max(data_min, -threshold), min(data_max, &
                                                                        threshold), &
                                         data_min, data_max, threshold, &
                                         tick_positions, num_ticks)
        end if

        ! Add positive log region ticks
        if (data_max > threshold) then
            call add_positive_symlog_ticks(max(threshold, data_min), data_max, &
                                           tick_positions, num_ticks)
        end if

        if (num_ticks > 1) then
            call sort_tick_positions(tick_positions, num_ticks)
        end if
    end subroutine compute_symlog_ticks

    function format_tick_label(value, scale_type, date_format, data_min, data_max) &
        result(label)
        !! Format a tick value as a string label
        !!
        !! value: Tick value to format
        !! scale_type: Scale type for context
        !! Returns label: Formatted label string

        real(wp), intent(in) :: value
        character(len=*), intent(in) :: scale_type
        character(len=*), intent(in), optional :: date_format
        real(wp), intent(in), optional :: data_min, data_max
        character(len=50) :: label
        real(wp) :: abs_value
        logical :: is_log_scale

        abs_value = abs(value)
        if (is_date_scale(scale_type)) then
            label = format_date_tick_label(value, scale_type, date_format, &
                                           data_min, data_max)
            label = adjustl(label)
            return
        end if
        is_log_scale = trim(scale_type) == 'log' .or. trim(scale_type) == 'symlog'

        if (abs_value <= epsilon(1.0_wp)) then
            label = '0'
        else if (is_log_scale .and. is_power_of_ten(value)) then
            ! Unify log and symlog formatting: show powers of ten with superscript
            label = format_power_of_ten_label(value)
        else if (trim(scale_type) == 'log') then
            ! Sub-decade log ticks (e.g. 4.2e1) render as m x 10^p, matching
            ! matplotlib's minor log labels when no decade tick is in range.
            label = format_log_mantissa_label(value)
        else if (.not. is_log_scale .and. abs_value < TICK_EPS) then
            label = '0'
        else if (abs_value >= SCIENTIFIC_THRESHOLD_HIGH .or. abs_value < &
                 SCIENTIFIC_THRESHOLD_LOW) then
            ! Use scientific notation for very large or very small values
            write (label, '(ES10.2)') value
            label = adjustl(label)
        else if (abs_value >= NO_DECIMAL_THRESHOLD) then
            ! No decimal places for values >= 100
            write (label, '(F0.0)') value
        else if (abs_value >= ONE_DECIMAL_THRESHOLD) then
            ! One decimal place for values >= 10
            write (label, '(F0.1)') value
        else if (abs_value >= TWO_DECIMAL_THRESHOLD) then
            ! Two decimal places for values >= 1
            write (label, '(F0.2)') value
        else
            ! Three decimal places for small values
            write (label, '(F0.3)') value
        end if

        label = adjustl(label)
    end function format_tick_label

    function calculate_nice_step(raw_step) result(nice_step)
        real(wp), intent(in) :: raw_step
        real(wp) :: nice_step, magnitude, normalized

        magnitude = 10.0_wp**floor(log10(raw_step))
        normalized = raw_step/magnitude

        ! Match matplotlib's AutoLocator step set {1, 2, 2.5, 5, 10}. This is the
        ! locator linear axes render with by default, not MaxNLocator's wider
        ! default {1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10}; using the latter produces
        ! base-1.5/3/etc. steps matplotlib never draws.
        if (normalized <= 1.0_wp) then
            nice_step = magnitude
        else if (normalized <= 2.0_wp) then
            nice_step = 2.0_wp*magnitude
        else if (normalized <= 2.5_wp) then
            nice_step = 2.5_wp*magnitude
        else if (normalized <= 5.0_wp) then
            nice_step = 5.0_wp*magnitude
        else
            nice_step = 10.0_wp*magnitude
        end if
    end function calculate_nice_step

    subroutine add_negative_symlog_ticks(data_min, upper_bound, &
                                          tick_positions, num_ticks)
        !! Add ticks for negative logarithmic region of symlog scale
        real(wp), intent(in) :: data_min, upper_bound
        real(wp), intent(inout) :: tick_positions(MAX_TICKS)
        integer, intent(inout) :: num_ticks

        real(wp) :: log_min, log_max, current_power
        integer :: start_power, end_power, power

        if (data_min >= 0.0_wp .or. upper_bound >= 0.0_wp .or. upper_bound <= &
            data_min) return

        ! Work with positive values for log calculations
        ! For negative range [-500, -1], we want powers that give us ticks in that range
        log_min = log10(-upper_bound)  ! log10(1) = 0 (closer to zero)
        log_max = log10(-data_min)     ! log10(500) = ~2.7 (larger magnitude)

        start_power = floor(log_min)
        end_power = ceiling(log_max)

        do power = start_power, end_power
            if (num_ticks >= MAX_TICKS) exit
            current_power = -(10.0_wp**power)

            if (current_power >= data_min - TICK_EPS .and. &
                current_power <= upper_bound + TICK_EPS) then
                if (.not. tick_exists(current_power, tick_positions, num_ticks)) then
                    num_ticks = num_ticks + 1
                    tick_positions(num_ticks) = current_power
                end if
            end if
        end do
    end subroutine add_negative_symlog_ticks

    subroutine add_linear_symlog_ticks(lower_bound, upper_bound, &
                                       data_min, data_max, threshold, &
                                       tick_positions, num_ticks)
        !! Add ticks for the linear region of a symlog scale.
        !!
        !! matplotlib's SymmetricalLogLocator behaves differently depending on
        !! whether the view reaches the log regions. When decade ticks already
        !! cover the view (data extends beyond linthresh), it places a major
        !! tick only at 0 inside the linear region and omits interior linear
        !! ticks (e.g. +/-5 for linthresh=10). When the entire view lies within
        !! linthresh it falls back to a linear locator, so interior ticks are
        !! kept (e.g. tick_values(-10,10,linthresh=50)=[-10,0,10]).
        real(wp), intent(in) :: lower_bound, upper_bound
        real(wp), intent(in) :: data_min, data_max, threshold
        real(wp), intent(inout) :: tick_positions(MAX_TICKS)
        integer, intent(inout) :: num_ticks

        real(wp) :: range, step, tick_value
        integer :: max_linear_ticks

        if (upper_bound <= lower_bound) return

        ! Always include zero if it's in the range
        if (lower_bound <= 0.0_wp .and. upper_bound >= 0.0_wp .and. num_ticks < &
            MAX_TICKS) then
            if (.not. tick_exists(0.0_wp, tick_positions, num_ticks)) then
                num_ticks = num_ticks + 1
                tick_positions(num_ticks) = 0.0_wp
            end if
        end if

        ! Suppress interior linear ticks once the log regions cover the view.
        if (data_min < -threshold .or. data_max > threshold) return

        range = upper_bound - lower_bound
        max_linear_ticks = 5  ! Reasonable number for linear region

        step = range/real(max_linear_ticks + 1, wp)
        step = calculate_nice_step(step)

        ! Find first tick >= lower_bound
        tick_value = ceiling(lower_bound/step)*step

        do while (tick_value <= upper_bound .and. num_ticks < MAX_TICKS)
            ! Skip zero if already added, avoid duplicates
            if (abs(tick_value) > 1.0e-10_wp) then
                if (.not. tick_exists(tick_value, tick_positions, num_ticks)) then
                    num_ticks = num_ticks + 1
                    tick_positions(num_ticks) = tick_value
                end if
            end if
            tick_value = tick_value + step
        end do
    end subroutine add_linear_symlog_ticks

    subroutine add_positive_symlog_ticks(lower_bound, data_max, &
                                         tick_positions, num_ticks)
        !! Add ticks for positive logarithmic region of symlog scale
        real(wp), intent(in) :: lower_bound, data_max
        real(wp), intent(inout) :: tick_positions(MAX_TICKS)
        integer, intent(inout) :: num_ticks

        real(wp) :: log_min, log_max, current_power
        integer :: start_power, end_power, power

        if (lower_bound <= 0.0_wp .or. data_max <= 0.0_wp) return

        log_min = log10(lower_bound)
        log_max = log10(data_max)

        start_power = floor(log_min)
        end_power = ceiling(log_max)

        do power = start_power, end_power
            if (num_ticks >= MAX_TICKS) exit
            current_power = 10.0_wp**power

            if (current_power >= lower_bound - TICK_EPS .and. &
                current_power <= data_max + TICK_EPS) then
                if (.not. tick_exists(current_power, tick_positions, num_ticks)) then
                    num_ticks = num_ticks + 1
                    tick_positions(num_ticks) = current_power
                end if
            end if
        end do
    end subroutine add_positive_symlog_ticks

    subroutine sort_tick_positions(values, count)
        real(wp), intent(inout) :: values(MAX_TICKS)
        integer, intent(in) :: count
        integer :: i, j
        real(wp) :: key

        if (count <= 1) return

        do i = 2, count
            key = values(i)
            j = i - 1
            do
                if (j < 1) exit
                if (values(j) <= key) exit
                values(j + 1) = values(j)
                j = j - 1
            end do
            values(j + 1) = key
        end do
    end subroutine sort_tick_positions

    function is_power_of_ten(value) result(is_power)
        real(wp), intent(in) :: value
        logical :: is_power
        real(wp) :: log_val
        log_val = log10(abs(value))
        is_power = abs(log_val - nint(log_val)) < 1.0e-10_wp
    end function is_power_of_ten

    ! format_power_of_ten moved to fortplot_tick_formatting to reduce duplication

    logical function tick_exists(value, tick_positions, num_ticks)
        real(wp), intent(in) :: value
        real(wp), intent(in) :: tick_positions(MAX_TICKS)
        integer, intent(in) :: num_ticks
        integer :: i
        real(wp) :: tolerance

        tolerance = TICK_EPS*max(1.0_wp, abs(value))
        tick_exists = .false.

        do i = 1, num_ticks
            if (abs(tick_positions(i) - value) <= tolerance) then
                tick_exists = .true.
                return
            end if
        end do
    end function tick_exists

end module fortplot_axes