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, int64 use fortplot_unicode, only: ascii_minus_to_unicode 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 public :: calculate_minor_tick_positions ! Tick magnitudes outside [SCI_LOWER, SCI_UPPER) use scientific labels ! (matplotlib's default axes.formatter.limits of -5 and 6). real(wp), parameter :: SCI_UPPER = 1.0e6_wp real(wp), parameter :: SCI_LOWER = 1.0e-5_wp ! Largest magnitude printed exactly as an integer label real(wp), parameter :: MAX_EXACT_INTEGER = 1.0e15_wp public :: calculate_log_minor_tick_positions 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 matching matplotlib's AutoLocator step set ! {1, 2, 2.5, 5, 10} -- the locator linear axes use by default. (Plain ! MaxNLocator's wider {1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10} is not what ! pyplot renders with and yields steps matplotlib never draws.) 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 <= 2.5_wp) then nice_normalized_step = 2.5_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) !! Decimal places needed to render the step exactly, matching !! matplotlib's ScalarFormatter. A step of 2.5 needs 1 decimal !! ("2.5"), 0.25 needs 2 ("0.25"); rounding to fewer would mislabel !! a tick (e.g. 2.5 -> "3"). The count is the fractional-digit depth !! of the step, not a function of its magnitude. real(wp), intent(in) :: step integer :: decimal_places integer, parameter :: MAX_DECIMALS = 6 real(wp) :: s, scaled integer :: d decimal_places = 0 s = abs(step) if (s <= 0.0_wp) return do d = 0, MAX_DECIMALS scaled = s * 10.0_wp**d if (abs(scaled - anint(scaled)) <= 1.0e-6_wp * max(1.0_wp, scaled)) then decimal_places = d return end if end do decimal_places = MAX_DECIMALS 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. !! Axes whose ticks are too large or too small for plain decimals !! (matplotlib's ScalarFormatter limits) get scientific labels, encoded !! as decimal_places = -(1 + mantissa decimals) for !! format_tick_value_consistent. real(wp), contiguous, intent(in) :: tick_positions(:) integer, intent(in) :: n integer :: decimal_places real(wp) :: step, d, vmax integer :: i, exponent decimal_places = 0 if (n < 2) return step = abs(tick_positions(2) - tick_positions(1)) vmax = maxval(abs(tick_positions(1:n))) do i = 3, n d = abs(tick_positions(i) - tick_positions(i-1)) if (d > 0.0_wp) step = min(step, d) end do if (vmax >= SCI_UPPER .or. (vmax > 0.0_wp .and. vmax < SCI_LOWER)) then exponent = floor(log10(vmax)) decimal_places = -(1 + determine_decimal_places_from_step( & step/10.0_wp**exponent)) return end if 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. !! Negative decimal_places select scientific labels (see !! determine_decimals_from_ticks); integers too large for exact !! integer output also fall back to scientific notation. real(wp), intent(in) :: value integer, intent(in) :: decimal_places character(len=20) :: formatted character(len=10) :: format_str if (decimal_places < 0) then formatted = format_scientific_tick(value, -decimal_places - 1) else 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 if (abs(value) < MAX_EXACT_INTEGER) then write(formatted, '(I0)') nint(value, int64) else formatted = format_scientific_tick(value, 0) end if else write(format_str, '(A, I0, A)') '(F0.', decimal_places, ')' write(formatted, format_str) value end if call ensure_leading_zero(formatted) formatted = ascii_minus_to_unicode(formatted) end function format_tick_value_consistent function format_scientific_tick(value, mantissa_decimals) result(formatted) !! "m.mme<exp>" label, e.g. 2.5e25 or 1.0e-25; zero stays "0". real(wp), intent(in) :: value integer, intent(in) :: mantissa_decimals character(len=20) :: formatted character(len=16) :: format_str, mantissa_text real(wp) :: mantissa integer :: exponent, m if (value == 0.0_wp .or. .not. (abs(value) <= huge(value))) then formatted = '0' return end if m = min(max(mantissa_decimals, 0), 6) exponent = floor(log10(abs(value))) mantissa = value/10.0_wp**exponent ! Rounding to m decimals may carry into the next decade (9.99 -> 10.0). if (abs(anint(mantissa*10.0_wp**m)) >= 10.0_wp**(m + 1)) then exponent = exponent + 1 mantissa = mantissa/10.0_wp end if write(format_str, '(A, I0, A)') '(F0.', m, ')' write(mantissa_text, format_str) mantissa if (m == 0) mantissa_text = mantissa_text(1:len_trim(mantissa_text) - 1) write(formatted, '(A, A, I0)') trim(mantissa_text), 'e', exponent end function format_scientific_tick 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 calculate_minor_tick_positions(major_ticks, num_major, minor_count, & data_min, data_max, & minor_ticks, num_minor) !! Calculate minor tick positions between major ticks !! Places minor_count minor ticks between each pair of major ticks real(wp), contiguous, intent(in) :: major_ticks(:) integer, intent(in) :: num_major integer, intent(in) :: minor_count real(wp), intent(in) :: data_min, data_max real(wp), intent(out) :: minor_ticks(:) integer, intent(out) :: num_minor integer :: i, j real(wp) :: step, pos num_minor = 0 if (num_major < 2 .or. minor_count < 1) return do i = 1, num_major - 1 step = (major_ticks(i + 1) - major_ticks(i)) / real(minor_count + 1, wp) do j = 1, minor_count pos = major_ticks(i) + real(j, wp) * step if (pos > data_min .and. pos < data_max) then if (num_minor < size(minor_ticks)) then num_minor = num_minor + 1 minor_ticks(num_minor) = pos end if end if end do end do end subroutine calculate_minor_tick_positions subroutine calculate_log_minor_tick_positions(major_ticks, num_major, & data_min, data_max, & minor_ticks, num_minor) !! Calculate minor tick positions for log scale at 2,3,4,5,6,7,8,9 within decades real(wp), contiguous, intent(in) :: major_ticks(:) integer, intent(in) :: num_major real(wp), intent(in) :: data_min, data_max real(wp), intent(out) :: minor_ticks(:) integer, intent(out) :: num_minor integer :: i, mult real(wp) :: decade_start, pos real(wp), parameter :: log_minors(8) = [2.0_wp, 3.0_wp, 4.0_wp, 5.0_wp, & 6.0_wp, 7.0_wp, 8.0_wp, 9.0_wp] num_minor = 0 if (num_major < 1 .or. data_min <= 0.0_wp) return do i = 1, num_major decade_start = major_ticks(i) if (decade_start <= 0.0_wp) cycle do mult = 1, 8 pos = decade_start * log_minors(mult) if (pos > data_min .and. pos < data_max) then if (num_minor < size(minor_ticks)) then num_minor = num_minor + 1 minor_ticks(num_minor) = pos end if end if end do end do end subroutine calculate_log_minor_tick_positions end module fortplot_tick_calculation