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