fortplot_figure_plot_renderers.f90 Source File


Source Code

module fortplot_figure_plot_renderers
    !! Individual plot type rendering procedures
    !!
    !! Single Responsibility: Render specific plot types (reflines, quivers,
    !! streamplots, polar data/axes).
    !! Called from fortplot_figure_plot_dispatch which handles orchestration.

    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_context
    use fortplot_plot_data, only: plot_data_t, arrow_data_t
    use fortplot_figure_initialization, only: figure_state_t
    use fortplot_polar_rendering, only: render_polar_data, render_polar_boundary, &
                                        render_polar_radial_gridlines, &
                                        render_polar_angular_gridlines, &
                                        render_polar_angular_ticks, &
                                        render_polar_radial_ticks
    implicit none

    private
    public :: render_refline_plot, render_quiver_plot
    public :: render_streamplot_arrows, render_polar_axes
    public :: render_polar_plot_internal

contains

    subroutine render_refline_plot(backend, plot, x_min, x_max, y_min, y_max, &
                                   xscale, yscale, symlog_threshold)
        !! Render a reference line (horizontal or vertical)
        !! Reference lines store normalized coordinates for axis-spanning lines
        !! or actual data coordinates for hlines/vlines
        use fortplot_scales, only: apply_scale_transform
        class(plot_context), intent(inout) :: backend
        type(plot_data_t), intent(in) :: plot
        real(wp), intent(in) :: x_min, x_max, y_min, y_max
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold

        real(wp) :: x1, y1, x2, y2
        real(wp) :: x1_scaled, y1_scaled, x2_scaled, y2_scaled
        logical :: x_normalized, y_normalized

        if (.not. allocated(plot%x) .or. .not. allocated(plot%y)) return
        if (size(plot%x) < 2 .or. size(plot%y) < 2) return

        call backend%color(plot%color(1), plot%color(2), plot%color(3))

        if (allocated(plot%linestyle) .and. len_trim(plot%linestyle) > 0) then
            call backend%set_line_style(trim(plot%linestyle))
        else
            call backend%set_line_style('-')
        end if

        x1 = plot%x(1)
        x2 = plot%x(2)
        y1 = plot%y(1)
        y2 = plot%y(2)
        x_normalized = .false.
        y_normalized = .false.

        ! For horizontal lines (y1 == y2), x values may be normalized
        if (abs(y1 - y2) < 1.0e-9_wp) then
            ! Horizontal line: check if x values are normalized (0-1)
            if (x1 >= 0.0_wp .and. x1 <= 1.0_wp .and. &
                x2 >= 0.0_wp .and. x2 <= 1.0_wp .and. &
                (x1 < 0.01_wp .or. x2 > 0.99_wp)) then
                ! x_min/x_max are already in transformed (e.g. log) space, so the
                ! interpolated span is too and must not be transformed again.
                x1 = x_min + x1*(x_max - x_min)
                x2 = x_min + x2*(x_max - x_min)
                x_normalized = .true.
            end if
        end if

        ! For vertical lines (x1 == x2), y values may be normalized
        if (abs(x1 - x2) < 1.0e-9_wp) then
            ! Vertical line: check if y values are normalized (0-1)
            if (y1 >= 0.0_wp .and. y1 <= 1.0_wp .and. &
                y2 >= 0.0_wp .and. y2 <= 1.0_wp .and. &
                (y1 < 0.01_wp .or. y2 > 0.99_wp)) then
                y1 = y_min + y1*(y_max - y_min)
                y2 = y_min + y2*(y_max - y_min)
                y_normalized = .true.
            end if
        end if

        ! Apply scale transforms only to axes still holding data coordinates;
        ! normalized spans were interpolated in transformed space already.
        if (x_normalized) then
            x1_scaled = x1
            x2_scaled = x2
        else
            x1_scaled = apply_scale_transform(x1, xscale, symlog_threshold)
            x2_scaled = apply_scale_transform(x2, xscale, symlog_threshold)
        end if
        if (y_normalized) then
            y1_scaled = y1
            y2_scaled = y2
        else
            y1_scaled = apply_scale_transform(y1, yscale, symlog_threshold)
            y2_scaled = apply_scale_transform(y2, yscale, symlog_threshold)
        end if

        ! Draw the line
        call backend%line(x1_scaled, y1_scaled, x2_scaled, y2_scaled)
    end subroutine render_refline_plot

    subroutine render_quiver_plot(backend, plot, x_min, x_max, y_min, y_max, &
                                  xscale, yscale, symlog_threshold)
        !! Render quiver plot (discrete vector arrows)
        !! Draws arrows at each (x,y) position with direction (u,v)
        !! Respects angles, pivot, alpha, and per-arrow c(:) color mapping.
        use fortplot_scales, only: apply_scale_transform
    use fortplot_colormap, only: colormap_value_to_color
    use fortplot_ascii, only: ascii_context
    class(plot_context), intent(inout) :: backend
        type(plot_data_t), intent(in) :: plot
        real(wp), intent(in) :: x_min, x_max, y_min, y_max
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold

        integer :: i, n
        real(wp) :: x_pos, y_pos, u_raw, v_raw
        real(wp) :: u_scaled, v_scaled, mag, max_mag
        real(wp) :: x_range, y_range, data_scale
        real(wp) :: scale, arrow_size
        real(wp) :: data_units_per_px, shaft_px
        real(wp) :: pivot_offset_x, pivot_offset_y
        real(wp) :: cmap_color(3)
        character(len=10) :: angles_mode
        character(len=10) :: pivot_mode
        character(len=:), allocatable :: cmap_name

        if (.not. allocated(plot%x) .or. .not. allocated(plot%y)) return
        if (.not. allocated(plot%quiver_u) .or. .not. allocated(plot%quiver_v)) return

        n = size(plot%x)
        if (n == 0) return
        if (size(plot%y) /= n .or. size(plot%quiver_u) /= n .or. &
            size(plot%quiver_v) /= n) return

        angles_mode = plot%quiver_angles
        pivot_mode = plot%quiver_pivot
        cmap_name = plot%quiver_colormap

        scale = plot%quiver_scale
        x_range = max(1.0e-9_wp, x_max - x_min)
        y_range = max(1.0e-9_wp, y_max - y_min)

        ! Compute max magnitude for scaling
        max_mag = 0.0_wp
        do i = 1, n
            mag = sqrt(plot%quiver_u(i)**2 + plot%quiver_v(i)**2)
            if (mag > max_mag) max_mag = mag
        end do
        if (max_mag < 1.0e-12_wp) max_mag = 1.0_wp

        ! Autoscaled shaft length: matplotlib's default keeps the longest
        ! arrow close to one grid step so arrows stay inside the axes box.
        ! The coefficient multiplies the shorter data range; the longest
        ! arrow then spans coef*range regardless of magnitude. The
        ! user-facing scale acts as a direct length multiplier (scale=0.5 ->
        ! half-length shafts), matching this library's example/gate semantics.
        data_scale = min(x_range, y_range)*0.095_wp*scale/max_mag

        ! On-screen pixels per data unit (canvas approximation); used to size
        ! arrow heads proportionally to shaft length, like matplotlib.
        data_units_per_px = x_range/max(1.0_wp, real(backend%width, wp))

        ! Compute pivot offset: how far the arrow base is from (x,y)
        ! pivot='tail': base at (x,y) -> offset = 0
        ! pivot='mid': base at midpoint -> offset = -0.5*vector
        ! pivot='tip': base at arrow tip -> offset = -1.0*vector
        pivot_offset_x = 0.0_wp
        pivot_offset_y = 0.0_wp
        if (trim(pivot_mode) == 'mid') then
            pivot_offset_x = -0.5_wp
            pivot_offset_y = -0.5_wp
        else if (trim(pivot_mode) == 'tip') then
            pivot_offset_x = -1.0_wp
            pivot_offset_y = -1.0_wp
        end if

        do i = 1, n
            x_pos = plot%x(i)
            y_pos = plot%y(i)
            u_raw = plot%quiver_u(i)
            v_raw = plot%quiver_v(i)

            ! Scale the vector
            u_scaled = u_raw * data_scale
            v_scaled = v_raw * data_scale
            mag = sqrt(u_scaled**2 + v_scaled**2)

            if (mag < 1.0e-12_wp) cycle

            ! Apply angles mode to compute arrow orientation
            if (trim(angles_mode) == 'xy') then
                ! Arrow points from (x,y) to (x+u, y+v)
                ! Already computed as u_scaled, v_scaled
            else
                ! Default 'uv' or 'native': use u,v directly
                ! Apply rotation if needed (not implemented for simplicity)
            end if

            ! Apply pivot offset to base position
            x_pos = x_pos + pivot_offset_x * u_scaled
            y_pos = y_pos + pivot_offset_y * v_scaled

            ! Set color: use c(:) colormap if present, else solid color
            if (allocated(plot%scatter_colors) .and. size(plot%scatter_colors) == n) then
                ! Map scalar c value through colormap
                if (allocated(cmap_name) .and. len_trim(cmap_name) > 0) then
                    call colormap_value_to_color(plot%scatter_colors(i), &
                                                minval(plot%scatter_colors), &
                                                maxval(plot%scatter_colors), &
                                                trim(cmap_name), &
                                                cmap_color)
                else
                    call colormap_value_to_color(plot%scatter_colors(i), &
                                                minval(plot%scatter_colors), &
                                                maxval(plot%scatter_colors), &
                                                'viridis', &
                                                cmap_color)
                end if
                call backend%color(cmap_color(1), cmap_color(2), cmap_color(3))
                ! Apply alpha with colormap-derived color
                if (plot%marker_face_alpha < 1.0_wp) then
                    call backend%set_marker_colors_with_alpha( &
                        cmap_color(1), cmap_color(2), cmap_color(3), &
                        plot%marker_edge_alpha, &
                        cmap_color(1), cmap_color(2), cmap_color(3), &
                        plot%marker_face_alpha)
                end if
            else
                call backend%color(plot%color(1), plot%color(2), plot%color(3))
                ! Apply alpha with solid color
                if (plot%marker_face_alpha < 1.0_wp) then
                    call backend%set_marker_colors_with_alpha( &
                        plot%color(1), plot%color(2), plot%color(3), &
                        plot%marker_edge_alpha, &
                        plot%color(1), plot%color(2), plot%color(3), &
                        plot%marker_face_alpha)
                end if
            end if

            call backend%set_line_style('-')

            ! Size the arrow head proportionally to the on-screen shaft length
            ! with matplotlib-like proportions: a slim head about a third of the
            ! shaft, clamped so short shafts keep a visible head and long shafts
            ! do not get overwhelmed by it. raster/vector backends draw a head
            ! of length size*8 pixels, so divide the desired head pixel length
            ! by 8.
            shaft_px = mag/max(1.0e-12_wp, data_units_per_px)
            arrow_size = min(16.0_wp, max(4.0_wp, shaft_px*0.33_wp))/8.0_wp

            select type (bk => backend)
            class is (ascii_context)
                call bk%draw_quiver_arrow(x_pos, y_pos, u_scaled, v_scaled)
            class default
                call backend%draw_arrow(x_pos, y_pos, u_scaled, v_scaled, &
                                        arrow_size, '->')
            end select
        end do
    end subroutine render_quiver_plot

    subroutine render_streamplot_arrows(backend, arrows)
        !! Render queued streamplot arrows after plot lines are drawn.
        !! Uses draw_arrowhead (head-only glyph) so a normalized direction
        !! vector does not get scaled into a data-coord shaft like draw_arrow
        !! does for quiver.
        !!
        !! The text backend routes each arrowhead through the cell layer policy
        !! (LAYER_DATA), so a direction marker occupies its plot cell without
        !! overwriting axes, ticks, or tick/axis labels (issue #2070). Raster
        !! and vector backends render the arrowhead geometry unchanged.
        class(plot_context), intent(inout) :: backend
        type(arrow_data_t), intent(in) :: arrows(:)
        integer :: i

        if (size(arrows) <= 0) return

        do i = 1, size(arrows)
            call backend%draw_arrowhead(arrows(i)%x, arrows(i)%y, arrows(i)%dx, &
                                        arrows(i)%dy, &
                                        arrows(i)%size, arrows(i)%style)
        end do
    end subroutine render_streamplot_arrows

    subroutine render_polar_axes(backend, x_min, x_max, y_min, y_max, state)
        !! Render polar axes: circular boundary, radial spokes, angular circles, tick labels
        use fortplot_ascii, only: ascii_context
        class(plot_context), intent(inout) :: backend
        real(wp), intent(in) :: x_min, x_max, y_min, y_max
        type(figure_state_t), intent(in) :: state

        real(wp) :: center_x, center_y, radius, r_max
        real(wp) :: theta_offset
        logical :: clockwise
        integer :: n_spokes, n_circles
        logical :: text_backend

        if (.not. state%polar_projection) return

        center_x = (x_min + x_max)*0.5_wp
        center_y = (y_min + y_max)*0.5_wp
        radius = min(x_max - x_min, y_max - y_min)*polar_radius_fraction(backend)
        r_max = state%polar_r_max

        theta_offset = state%polar_theta_offset
        clockwise = state%polar_theta_direction_cw
        n_spokes = state%polar_theta_gridlines
        n_circles = state%polar_r_gridlines

        ! A character grid cannot render thin gray gridlines, so the concentric
        ! circles and spokes collapse into a solid mass that buries the labels
        ! and curve. Text output keeps only the boundary frame and the labels,
        ! matching how gnuplot's dumb terminal and plotext draw polar axes.
        text_backend = .false.
        select type (backend)
        class is (ascii_context)
            text_backend = .true.
        end select

        if (.not. text_backend) then
            ! Render concentric circles (angular gridlines)
            call render_polar_angular_gridlines(backend, center_x, center_y, &
                                                radius, n_circles)

            ! Render radial spokes
            call render_polar_radial_gridlines(backend, center_x, center_y, &
                                               radius, n_spokes, theta_offset, &
                                               clockwise)
        end if

        ! Render circular boundary
        call render_polar_boundary(backend, center_x, center_y, radius)

        ! Render angular tick labels
        call render_polar_angular_ticks(backend, center_x, center_y, radius, &
                                        n_spokes, theta_offset, clockwise)

        ! Render radial tick labels along a spoke
        call render_polar_radial_ticks(backend, center_x, center_y, radius, r_max)
    end subroutine render_polar_axes

    subroutine render_polar_plot_internal(backend, plot, x_min, x_max, y_min, y_max, &
                                          state)
        !! Render polar plot data within the coordinate system
        !! The plot stores pre-converted Cartesian coordinates in x/y arrays
        !! but we use polar_theta/polar_r for proper polar rendering
        use fortplot_ascii, only: ascii_context
        use fortplot_ascii_polar, only: render_polar_data_text
        class(plot_context), intent(inout) :: backend
        type(plot_data_t), intent(in) :: plot
        real(wp), intent(in) :: x_min, x_max, y_min, y_max
        type(figure_state_t), intent(in), optional :: state

        real(wp) :: center_x, center_y, radius, r_scale, r_max
        real(wp) :: theta_offset
        logical :: clockwise
        integer :: n

        ! Compute center and radius in data coordinates
        center_x = (x_min + x_max)*0.5_wp
        center_y = (y_min + y_max)*0.5_wp
        radius = min(x_max - x_min, y_max - y_min)*polar_radius_fraction(backend)

        ! Get polar configuration from state
        theta_offset = 0.0_wp  ! 0 deg at east (matplotlib)
        clockwise = .false.
        if (present(state)) then
            theta_offset = state%polar_theta_offset
            clockwise = state%polar_theta_direction_cw
        end if

        ! Compute r_scale from the shared radial axis range so every curve uses
        ! the same scale (matplotlib draws all polar series against one r-axis)
        ! and aligns with the rendered radial tick labels. Fall back to the
        ! per-plot maximum only when no shared range is available.
        r_scale = 1.0_wp
        r_max = 1.0_wp
        if (present(state)) then
            if (state%polar_r_max > 0.0_wp) then
                r_scale = radius/state%polar_r_max
                r_max = state%polar_r_max
            end if
        else if (allocated(plot%polar_r)) then
            if (size(plot%polar_r) > 0) then
                r_max = maxval(abs(plot%polar_r))
                if (r_max > 0.0_wp) r_scale = radius/r_max
            end if
        end if

        ! Render polar data if available
        if (allocated(plot%polar_theta) .and. allocated(plot%polar_r)) then
            n = min(size(plot%polar_theta), size(plot%polar_r))
            if (n > 0) then
                select type (bk => backend)
                class is (ascii_context)
                    call render_polar_data_text(bk, plot%polar_theta, plot%polar_r, &
                                                n, center_x, center_y, radius, r_max, &
                                                theta_offset, clockwise, x_min, x_max, &
                                                y_min, y_max, polar_series_glyph(plot))
                class default
                    call render_polar_data(backend, plot%polar_theta, plot%polar_r, &
                                           n, center_x, center_y, r_scale, &
                                           theta_offset, clockwise, plot%color)
                end select
            end if
        end if
    end subroutine render_polar_plot_internal

    pure real(wp) function polar_radius_fraction(backend) result(fraction)
        use fortplot_ascii, only: ascii_context
        class(plot_context), intent(in) :: backend

        fraction = 0.40_wp
        select type (backend)
        class is (ascii_context)
            fraction = 0.45_wp
        end select
    end function polar_radius_fraction

    pure character(len=1) function polar_series_glyph(plot) result(glyph)
        !! Pick a text glyph for a polar series from its marker, restricted to
        !! the data-layer glyph set so tick labels can still overwrite it.
        type(plot_data_t), intent(in) :: plot

        glyph = '*'
        if (.not. allocated(plot%marker)) return
        select case (trim(plot%marker))
        case ('o')
            glyph = 'o'
        case ('s')
            glyph = '#'
        case ('D', 'd')
            glyph = '%'
        case default
            glyph = '*'
        end select
    end function polar_series_glyph

end module fortplot_figure_plot_renderers