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