fortplot_figure_core_advanced.f90 Source File


Source Code

module fortplot_figure_core_advanced
    !! Advanced plotting operations extracted from fortplot_figure_core
    !!
    !! This module contains advanced plotting functionality like scatter plots,
    !! histograms, and statistical plots that were moved from the core module
    !! to maintain architectural compliance with size limits.

    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_plot_data, only: plot_data_t, PLOT_TYPE_BOXPLOT
    use fortplot_figure_initialization, only: figure_state_t, ensure_figure_storage
    use fortplot_figure_operations
    use fortplot_figure_core_ranges, only: update_data_ranges_figure
    implicit none

    private
    public :: core_scatter, core_hist, core_boxplot, core_colorbar

contains

  subroutine core_scatter(plots, state, plot_count, x, y, s, c, marker, &
                             markersize, &
                             color, colormap, alpha, edgecolor, facecolor, &
                             linewidth, &
                             vmin, vmax, label, show_colorbar, default_color)
        !! Add an efficient scatter plot using a single plot object
        !! Properly handles thousands of points without O(n) overhead
        type(plot_data_t), allocatable, intent(inout) :: plots(:)
        type(figure_state_t), intent(inout) :: state
        integer, intent(inout) :: plot_count
        real(wp), contiguous, intent(in) :: x(:), y(:)
        real(wp), intent(in), optional :: s(..), c(:)
        character(len=*), intent(in), optional :: marker, colormap, label
        real(wp), intent(in), optional :: markersize, alpha, linewidth, vmin, vmax
        real(wp), intent(in), optional :: color(3), edgecolor(3), facecolor(3)
        logical, intent(in), optional :: show_colorbar
        real(wp), intent(in) :: default_color(3)

        ! Delegate to efficient scatter implementation
        call ensure_figure_storage(plots, state)
        call figure_scatter_operation(state, plots, plot_count, &
                                      x, y, s, c, marker, markersize, color, &
                                      colormap, alpha, edgecolor, facecolor, &
                                      linewidth, vmin, vmax, label, show_colorbar, &
                                      default_color)

        ! Sync plot_count back to state and update data ranges
        state%plot_count = plot_count
        call update_data_ranges_figure(plots, state, state%plot_count)
    end subroutine core_scatter

    subroutine core_hist(plots, state, plot_count, data, bins, density, label, color, &
                         range, weights, cumulative, orientation, alpha)
        !! Create a histogram plot (matplotlib-compatible).
        type(plot_data_t), allocatable, intent(inout) :: plots(:)
        type(figure_state_t), intent(inout) :: state
        integer, intent(inout) :: plot_count
        real(wp), contiguous, intent(in) :: data(:)
        integer, intent(in), optional :: bins
        logical, intent(in), optional :: density
        character(len=*), intent(in), optional :: label
        real(wp), intent(in), optional :: color(3)
        real(wp), intent(in), optional :: range(2)
        real(wp), intent(in), optional :: weights(:)
        logical, intent(in), optional :: cumulative
        character(len=*), intent(in), optional :: orientation
        real(wp), intent(in), optional :: alpha

        call ensure_figure_storage(plots, state)
        call figure_hist_operation(plots, state, plot_count, data, bins, density, &
                                   label, color, range=range, weights=weights, &
                                   cumulative=cumulative, orientation=orientation, &
                                   alpha=alpha)
    end subroutine core_hist

  subroutine core_boxplot(plots, state, plot_count, data, position, width, label, &
                             show_outliers, horizontal, color, max_plots)
        !! Create a box plot
        type(plot_data_t), allocatable, intent(inout) :: plots(:)
        type(figure_state_t), intent(inout) :: state
        integer, intent(inout) :: plot_count
        real(wp), contiguous, intent(in) :: data(:)
        real(wp), intent(in), optional :: position
        real(wp), intent(in), optional :: width
        character(len=*), intent(in), optional :: label
        logical, intent(in), optional :: show_outliers
        logical, intent(in), optional :: horizontal
        real(wp), intent(in), optional :: color(3)
        integer, intent(in) :: max_plots

        call ensure_figure_storage(plots, state)
        call figure_boxplot_operation(state, plots, plot_count, data, position, &
                                      width, label, &
                                      show_outliers, horizontal, color, max_plots)
        call apply_boxplot_category_ticks(plots, state, plot_count)
    end subroutine core_boxplot

    subroutine apply_boxplot_category_ticks(plots, state, plot_count)
        !! Pin the category axis to the box positions, as matplotlib's bxp does.
        !!
        !! A box plot's category axis is discrete, but it was left to the linear
        !! locator, which produced ticks between the boxes: three boxes at 1, 2, 3
        !! were labelled 1.0, 1.5, 2.0, 2.5, 3.0, and half of those mark nothing.
        !! matplotlib places a tick per box and nowhere else.
        !!
        !! Auto-applied ticks are flagged so an explicit set_xticks by the caller
        !! wins and is never overwritten by a later boxplot call.
        type(plot_data_t), intent(in) :: plots(:)
        type(figure_state_t), intent(inout) :: state
        integer, intent(in) :: plot_count

        real(wp), allocatable :: positions(:)
        character(len=50), allocatable :: labels(:)
        integer :: i, n
        logical :: horizontal

        n = 0
        horizontal = .false.
        allocate (positions(max(plot_count, 1)), labels(max(plot_count, 1)))
        do i = 1, min(plot_count, size(plots))
            if (plots(i)%plot_type /= PLOT_TYPE_BOXPLOT) cycle
            n = n + 1
            positions(n) = plots(i)%position
            labels(n) = position_label(plots(i)%position)
            if (plots(i)%horizontal) horizontal = .true.
        end do
        if (n == 0) return

        ! A horizontal box plot puts the categories on y instead. Only the axis
        ! actually being written is checked for a caller-supplied override, so
        ! that setting y ticks by hand does not suppress the x categories.
        if (horizontal) then
            if (state%custom_yticks_set .and. .not. state%auto_category_ticks) return
            state%custom_ytick_positions = positions(1:n)
            state%custom_ytick_labels = labels(1:n)
            state%custom_yticks_set = .true.
        else
            if (state%custom_xticks_set .and. .not. state%auto_category_ticks) return
            state%custom_xtick_positions = positions(1:n)
            state%custom_xtick_labels = labels(1:n)
            state%custom_xticks_set = .true.
        end if
        state%auto_category_ticks = .true.
    end subroutine apply_boxplot_category_ticks

    function position_label(position) result(text)
        !! Format a box position the way matplotlib labels it: an integral
        !! position reads '1', not '1.0', and a fractional one keeps only the
        !! digits it needs, so 0.5 reads '0.5' rather than '0.5000'.
        real(wp), intent(in) :: position
        character(len=50) :: text
        integer :: last

        if (abs(position - anint(position)) < 1.0e-9_wp) then
            write (text, '(I0)') nint(position)
            return
        end if

        ! F0.6 drops the leading zero ('.5'), which reads as a typo on an axis.
        write (text, '(F0.6)') position
        text = adjustl(text)
        if (text(1:1) == '.') then
            text = '0'//trim(text)
        else if (len_trim(text) > 1) then
            if (text(1:2) == '-.') text = '-0'//trim(text(2:))
        end if
        last = len_trim(text)
        do while (last > 1)
            if (text(last:last) /= '0') exit
            text(last:last) = ' '
            last = last - 1
        end do
        if (text(last:last) == '.') text(last:last) = ' '
        text = adjustl(text)
    end function position_label

    subroutine core_colorbar(state, plots, plot_count, plot_index, label, location, &
                             fraction, pad, shrink, ticks, ticklabels, label_fontsize)
        !! Enable a stateful colorbar for the current figure.
        !!
        !! This mirrors matplotlib's pyplot behavior: the colorbar is configured
        !! independently from plot creation and is rendered during save/show.
        type(figure_state_t), intent(inout) :: state
        type(plot_data_t), intent(in) :: plots(:)
        integer, intent(in) :: plot_count
        integer, intent(in), optional :: plot_index
        character(len=*), intent(in), optional :: label, location
        real(wp), intent(in), optional :: fraction, pad, shrink
        real(wp), intent(in), optional :: ticks(:)
        character(len=*), intent(in), optional :: ticklabels(:)
        real(wp), intent(in), optional :: label_fontsize

        integer :: idx, i

        associate (dummy => size(plots)); end associate

        if (plot_count <= 0) then
            state%colorbar_enabled = .false.
            state%colorbar_plot_index = 0
            return
        end if

        idx = 0
        if (present(plot_index)) then
            if (plot_index >= 1 .and. plot_index <= plot_count) then
                idx = plot_index
            end if
        end if

        if (idx == 0) then
            idx = plot_count
        end if

        state%colorbar_enabled = .true.
        state%colorbar_plot_index = idx

        if (present(location)) then
            if (len_trim(location) > 0) state%colorbar_location = trim(location)
        end if

        if (present(fraction)) then
            state%colorbar_fraction = max(0.01_wp, min(0.45_wp, fraction))
        end if

        if (present(pad)) then
            state%colorbar_pad = max(0.0_wp, min(0.30_wp, pad))
        end if

        if (present(shrink)) then
            state%colorbar_shrink = max(0.05_wp, min(1.0_wp, shrink))
        end if

        state%colorbar_label_set = .false.
        if (allocated(state%colorbar_label)) deallocate (state%colorbar_label)
        if (present(label)) then
            if (len_trim(label) > 0) then
                state%colorbar_label = trim(label)
                state%colorbar_label_set = .true.
            end if
        end if

        state%colorbar_ticks_set = .false.
        if (allocated(state%colorbar_ticks)) deallocate (state%colorbar_ticks)
        if (present(ticks)) then
            if (size(ticks) > 0) then
                allocate (state%colorbar_ticks(size(ticks)))
                state%colorbar_ticks = ticks
                state%colorbar_ticks_set = .true.
            end if
        end if

        state%colorbar_ticklabels_set = .false.
        if (allocated(state%colorbar_ticklabels)) deallocate (state%colorbar_ticklabels)
        if (present(ticklabels)) then
            if (size(ticklabels) > 0) then
                allocate (state%colorbar_ticklabels(size(ticklabels)))
                do i = 1, size(ticklabels)
                    state%colorbar_ticklabels(i) = trim(ticklabels(i))
                end do
                state%colorbar_ticklabels_set = .true.
            end if
        end if

        if (present(label_fontsize)) then
            state%colorbar_label_fontsize = max(4.0_wp, min(72.0_wp, label_fontsize))
        end if
    end subroutine core_colorbar

end module fortplot_figure_core_advanced