fortplot_ascii_secondary_axes.f90 Source File


Source Code

module fortplot_ascii_secondary_axes
    !! ASCII backend secondary axis rendering
    !!
    !! Handles twin-axes tick labels for the ASCII backend.
    !! Separated from the rendering pipeline to keep module size compliant.
    !!
    !! Secondary labels are placed in reserved bands outside the primary data
    !! area: the secondary y-axis labels go in the right-hand columns beyond the
    !! data plot area, and the secondary x-axis (top) labels go in the reserved
    !! rows above it (issue #2066). The reservation itself shrinks the ASCII
    !! plot area before data is drawn, so labels never overwrite plotted glyphs
    !! or the axis spines.

    use fortplot_axes, only: compute_scale_ticks, format_tick_label, MAX_TICKS
    use fortplot_tick_calculation, only: determine_decimals_from_ticks, &
                                         format_tick_value_consistent
    use fortplot_ascii, only: ascii_context
    use fortplot_ascii_mathtext, only: sanitize_ascii_text
    use, intrinsic :: iso_fortran_env, only: wp => real64
    implicit none

    private
    public :: ascii_draw_secondary_y_axis, ascii_draw_secondary_x_axis_top
    public :: ascii_secondary_y_tick_width

    integer, parameter :: SECONDARY_TICK_PAD = 2

contains

    integer function ascii_secondary_y_tick_width(yscale, symlog_threshold, &
                                                  y_min, y_max, date_format) &
        result(width)
        !! Width in character cells needed to hold the widest secondary y tick
        !! label, plus one column of padding from the data area. Used to reserve
        !! the right-hand band before data is drawn.
        character(len=*), intent(in) :: yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(in) :: y_min, y_max
        character(len=*), intent(in), optional :: date_format

        real(wp) :: positions(MAX_TICKS)
        character(len=50) :: labels(MAX_TICKS)
        integer :: num, max_len

        call compute_secondary_y_labels(yscale, symlog_threshold, y_min, y_max, &
                                        date_format, positions, labels, num, max_len)
        if (num <= 0 .or. max_len <= 0) then
            width = 0
        else
            width = max_len + SECONDARY_TICK_PAD
        end if
    end function ascii_secondary_y_tick_width

    subroutine compute_secondary_y_labels(yscale, symlog_threshold, y_min, y_max, &
                                          date_format, positions, labels, num, max_len)
        !! Compute secondary y tick positions and their rendered labels once so
        !! the reserved band width and the drawn labels stay consistent.
        character(len=*), intent(in) :: yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(in) :: y_min, y_max
        character(len=*), intent(in), optional :: date_format
        real(wp), intent(out) :: positions(MAX_TICKS)
        character(len=50), intent(out) :: labels(MAX_TICKS)
        integer, intent(out) :: num, max_len

        integer :: decimals, i, sanitized_len
        character(len=50) :: raw_label
        character(len=500) :: sanitized

        labels = ''
        max_len = 0
        call compute_scale_ticks(yscale, y_min, y_max, symlog_threshold, positions, num)
        if (num <= 0) return

        decimals = 0
        if (trim(yscale) == 'linear' .and. num >= 2) then
            decimals = determine_decimals_from_ticks(positions, num)
        end if

        do i = 1, num
            if (trim(yscale) == 'linear') then
                raw_label = format_tick_value_consistent(positions(i), decimals)
            else
                raw_label = format_tick_label(positions(i), yscale, &
                    date_format=date_format, data_min=y_min, data_max=y_max)
            end if
            ! Store the ASCII-rendered form so the reserved band width matches
            ! what actually prints (LaTeX markup is stripped on render).
            call sanitize_ascii_text(trim(raw_label), sanitized, sanitized_len)
            labels(i) = sanitized(1:min(sanitized_len, len(labels(i))))
            max_len = max(max_len, len_trim(labels(i)))
        end do
    end subroutine compute_secondary_y_labels

    subroutine ascii_draw_secondary_x_axis_top(backend, xscale, symlog_threshold, &
                                               x_min, x_max, xlabel, date_format)
        !! Draw top x-axis tick labels for ASCII backend in the reserved top band
        type(ascii_context), intent(inout) :: backend
        character(len=*), intent(in) :: xscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(in) :: x_min, x_max
        character(len=:), allocatable, intent(in), optional :: xlabel
        character(len=*), intent(in), optional :: date_format

        real(wp) :: x_tick_positions(MAX_TICKS)
        character(len=50) :: tick_label
        integer :: num_x_ticks, decimals, i
        integer :: text_x, tick_row, label_row, start_col, label_len
        integer :: left, width, inner
        real(wp) :: frac

        call compute_scale_ticks(xscale, x_min, x_max, symlog_threshold, &
                                 x_tick_positions, num_x_ticks)
        if (num_x_ticks <= 0) return
        if (x_max <= x_min) return

        decimals = 0
        if (trim(xscale) == 'linear' .and. num_x_ticks >= 2) then
            decimals = determine_decimals_from_ticks(x_tick_positions, num_x_ticks)
        end if

        left = backend%plot_area%left
        width = max(1, backend%plot_area%width)
        inner = max(1, width - 2)

        tick_row = max(1, backend%plot_area%bottom)
        label_row = 1

        do i = 1, num_x_ticks
            if (trim(xscale) == 'linear') then
                tick_label = format_tick_value_consistent(x_tick_positions(i), decimals)
            else
                tick_label = format_tick_label(x_tick_positions(i), xscale, &
                    date_format=date_format, data_min=x_min, data_max=x_max)
            end if

            frac = (x_tick_positions(i) - x_min)/(x_max - x_min)
            text_x = left + 1 + nint(frac*real(inner, wp))
            label_len = len_trim(tick_label)
            start_col = text_x - label_len/2
            start_col = max(left + 1, min(start_col, left + width - label_len))
            start_col = max(1, min(start_col, backend%plot_width - label_len))

            call backend%text(real(start_col, wp), real(tick_row, wp), &
                              trim(tick_label))
        end do

        ! Split the optional guards: .and. does not short-circuit, so allocated()
        ! and len_trim() must not be reached when xlabel is absent.
        if (present(xlabel)) then
            if (allocated(xlabel)) then
                if (len_trim(xlabel) > 0) then
                    label_len = len_trim(xlabel)
                    start_col = left + max(1, (width - label_len)/2)
                    start_col = max(1, min(start_col, backend%plot_width - label_len))
                    call backend%text(real(start_col, wp), real(label_row, wp), &
                                      trim(xlabel))
                end if
            end if
        end if
    end subroutine ascii_draw_secondary_x_axis_top

    subroutine ascii_draw_secondary_y_axis(backend, yscale, symlog_threshold, &
                                           y_min, y_max, ylabel, date_format)
        !! Draw right y-axis tick labels for ASCII backend in the reserved band
        !! to the right of the primary data area.
        type(ascii_context), intent(inout) :: backend
        character(len=*), intent(in) :: yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(in) :: y_min, y_max
        character(len=:), allocatable, intent(in), optional :: ylabel
        character(len=*), intent(in), optional :: date_format

        real(wp) :: y_tick_positions(MAX_TICKS)
        character(len=50) :: labels(MAX_TICKS)
        integer :: num_y_ticks, i, max_len
        integer :: text_x, text_y, label_len
        integer :: bottom, height, band_x

        ! ylabel is part of the shared secondary-axis interface; the ASCII
        ! backend renders it via the figure ylabel line, not the tick band.
        ! Guard present/allocated separately (.and. does not short-circuit) and
        ! associate an integer: flang rejects associating the bare deferred-
        ! length optional dummy in a specification expression.
        if (present(ylabel)) then
            if (allocated(ylabel)) then
                associate (unused => len_trim(ylabel)); end associate
            end if
        end if

        call compute_secondary_y_labels(yscale, symlog_threshold, y_min, y_max, &
                                        date_format, y_tick_positions, labels, &
                                        num_y_ticks, max_len)
        if (num_y_ticks <= 0) return
        if (y_max <= y_min) return

        bottom = backend%plot_area%bottom
        height = max(1, backend%plot_area%height)
        band_x = backend%plot_area%left + backend%plot_area%width + SECONDARY_TICK_PAD

        do i = 1, num_y_ticks
            label_len = len_trim(labels(i))
            if (label_len <= 0) cycle

            text_y = bottom + height - 1 - &
                     nint((y_tick_positions(i) - y_min)/(y_max - y_min)* &
                     real(max(1, height - 2), wp))
            text_y = max(bottom + 2, min(text_y, bottom + height - 2))
            text_y = max(1, min(text_y, backend%plot_height))

            text_x = min(band_x, backend%plot_width - label_len)
            text_x = max(1, text_x)

            call backend%text(real(text_x, wp), real(text_y, wp), trim(labels(i)))
        end do
    end subroutine ascii_draw_secondary_y_axis

end module fortplot_ascii_secondary_axes