module fortplot_polar_rendering !! Polar projection rendering for polygon backends !! !! Renders circular plot boundary, radial gridlines (spokes), !! angular gridlines (concentric circles), and angular tick labels. use, intrinsic :: iso_fortran_env, only: wp => real64 use fortplot_context use fortplot_ascii, only: ascii_context use fortplot_polar, only: polar_to_cartesian, compute_angular_ticks, & compute_radial_ticks, PI, TWO_PI, RAD_TO_DEG use fortplot_tick_calculation, only: calculate_tick_labels implicit none private public :: render_polar_boundary public :: render_polar_radial_gridlines public :: render_polar_angular_gridlines public :: render_polar_angular_ticks public :: render_polar_radial_ticks public :: render_polar_data integer, parameter :: CIRCLE_SEGMENTS = 72 ! 5-degree resolution contains subroutine render_polar_boundary(backend, center_x, center_y, radius, & line_width, color) !! Render circular boundary for polar plot class(plot_context), intent(inout) :: backend real(wp), intent(in) :: center_x, center_y, radius real(wp), intent(in), optional :: line_width real(wp), intent(in), optional :: color(3) real(wp) :: angle, x1, y1, x2, y2 real(wp) :: lw, c(3) integer :: i ! matplotlib renders the polar outer spine as a thin gray circle ! (0.8pt grid weight), not a heavy black boundary. lw = 0.8_wp if (present(line_width)) lw = line_width c = [0.5_wp, 0.5_wp, 0.5_wp] ! Gray default if (present(color)) c = color call backend%color(c(1), c(2), c(3)) call backend%set_line_width(lw) call backend%set_line_style('-') do i = 1, CIRCLE_SEGMENTS angle = TWO_PI*real(i - 1, wp)/real(CIRCLE_SEGMENTS, wp) x1 = center_x + radius*cos(angle) y1 = center_y + radius*sin(angle) angle = TWO_PI*real(i, wp)/real(CIRCLE_SEGMENTS, wp) x2 = center_x + radius*cos(angle) y2 = center_y + radius*sin(angle) call backend%line(x1, y1, x2, y2) end do end subroutine render_polar_boundary subroutine render_polar_radial_gridlines(backend, center_x, center_y, & radius, n_spokes, & theta_offset, clockwise, & color, alpha) !! Render radial gridlines (spokes) from center to boundary class(plot_context), intent(inout) :: backend real(wp), intent(in) :: center_x, center_y, radius integer, intent(in) :: n_spokes real(wp), intent(in) :: theta_offset logical, intent(in) :: clockwise real(wp), intent(in), optional :: color(3) real(wp), intent(in), optional :: alpha real(wp) :: theta, x_end, y_end real(wp) :: c(3), step integer :: i if (n_spokes <= 0) return c = [0.7_wp, 0.7_wp, 0.7_wp] ! Gray default if (present(color)) c = color call backend%color(c(1), c(2), c(3)) call backend%set_line_style(':') ! 0.8pt matches matplotlib's grid.linewidth default (which inherits ! from axes.linewidth = 0.8) so polar gridlines render at the same ! weight as Cartesian gridlines and the surrounding axes frame. call backend%set_line_width(0.8_wp) step = TWO_PI/real(n_spokes, wp) do i = 1, n_spokes theta = real(i - 1, wp)*step call polar_to_cartesian(theta, radius, x_end, y_end, & theta_offset, clockwise) x_end = center_x + x_end y_end = center_y + y_end call backend%line(center_x, center_y, x_end, y_end) end do call backend%set_line_style('-') end subroutine render_polar_radial_gridlines subroutine render_polar_angular_gridlines(backend, center_x, center_y, & r_max, n_circles, & color, alpha) !! Render angular gridlines (concentric circles) class(plot_context), intent(inout) :: backend real(wp), intent(in) :: center_x, center_y, r_max integer, intent(in) :: n_circles real(wp), intent(in), optional :: color(3) real(wp), intent(in), optional :: alpha real(wp) :: r_step, r_current real(wp) :: c(3) integer :: i if (n_circles <= 0) return c = [0.7_wp, 0.7_wp, 0.7_wp] ! Gray default if (present(color)) c = color call backend%color(c(1), c(2), c(3)) call backend%set_line_style(':') ! 0.8pt matches matplotlib's grid.linewidth default (which inherits ! from axes.linewidth = 0.8) so polar gridlines render at the same ! weight as Cartesian gridlines and the surrounding axes frame. call backend%set_line_width(0.8_wp) r_step = r_max/real(n_circles + 1, wp) do i = 1, n_circles r_current = r_step*real(i, wp) call render_circle(backend, center_x, center_y, r_current) end do call backend%set_line_style('-') end subroutine render_polar_angular_gridlines subroutine render_circle(backend, cx, cy, radius) !! Helper: render a circle as line segments class(plot_context), intent(inout) :: backend real(wp), intent(in) :: cx, cy, radius real(wp) :: angle, x1, y1, x2, y2 integer :: i do i = 1, CIRCLE_SEGMENTS angle = TWO_PI*real(i - 1, wp)/real(CIRCLE_SEGMENTS, wp) x1 = cx + radius*cos(angle) y1 = cy + radius*sin(angle) angle = TWO_PI*real(i, wp)/real(CIRCLE_SEGMENTS, wp) x2 = cx + radius*cos(angle) y2 = cy + radius*sin(angle) call backend%line(x1, y1, x2, y2) end do end subroutine render_circle subroutine render_polar_angular_ticks(backend, center_x, center_y, radius, & n_ticks, theta_offset, clockwise, & label_offset) !! Render angular tick labels around the polar plot boundary class(plot_context), intent(inout) :: backend real(wp), intent(in) :: center_x, center_y, radius integer, intent(in) :: n_ticks real(wp), intent(in) :: theta_offset logical, intent(in) :: clockwise real(wp), intent(in), optional :: label_offset real(wp) :: theta, x_label, y_label, label_r real(wp) :: tick_angles(36) character(len=8) :: tick_labels(36) integer :: i, n if (n_ticks <= 0) return n = min(n_ticks, 36) call compute_angular_ticks(n, tick_angles(1:n), tick_labels(1:n)) label_r = radius*1.12_wp if (present(label_offset)) label_r = radius + label_offset call backend%color(0.0_wp, 0.0_wp, 0.0_wp) do i = 1, n call polar_to_cartesian(tick_angles(i), label_r, x_label, y_label, & theta_offset, clockwise) x_label = center_x + x_label y_label = center_y + y_label call backend%text(x_label, y_label, trim(tick_labels(i))) end do end subroutine render_polar_angular_ticks subroutine render_polar_radial_ticks(backend, center_x, center_y, radius, & r_max, label_angle) !! Render radial tick labels along a spoke, matching matplotlib's polar !! r-axis labels (e.g. 0.2, 0.4, ... at 22.5 degrees by default). class(plot_context), intent(inout) :: backend real(wp), intent(in) :: center_x, center_y, radius, r_max real(wp), intent(in), optional :: label_angle ! matplotlib auto-scales the radial axis to the data maximum, while the ! caller passes a padded outer radius (data_max * R_MAX_PAD) so the ! boundary clears the curve. Undo that padding before picking tick ! values so the labels stop at the data max (e.g. 1.2, not 1.4) the way ! matplotlib does, but keep the geometry scaled by the padded r_max. real(wp), parameter :: R_MAX_PAD = 1.1_wp real(wp), parameter :: TEXT_LABEL_X_SHIFT = 0.04_wp character(len=20) :: labels(12) real(wp) :: r_value, r_geom, angle, x_label, y_label, r_data integer :: i, ios logical :: text_backend if (r_max <= 0.0_wp .or. radius <= 0.0_wp) return ! matplotlib's default rlabel position places the radial labels along a ! ray 22.5 degrees above the horizontal (0-degree) axis, so they stack ! up and to the right rather than running flat along the 0-degree spoke. angle = PI/8.0_wp ! 22.5 degrees in screen coordinates if (present(label_angle)) angle = label_angle r_data = r_max/R_MAX_PAD text_backend = .false. select type (backend) class is (ascii_context) text_backend = .true. end select ! Use the linear tick algorithm to pick nice radial values over the data ! range [0, r_data] so the outermost label matches the data maximum. labels = '' call calculate_tick_labels(0.0_wp, r_data, size(labels), labels) call backend%color(0.0_wp, 0.0_wp, 0.0_wp) do i = 1, size(labels) if (len_trim(labels(i)) == 0) cycle read (labels(i), *, iostat=ios) r_value if (ios /= 0) cycle if (r_value <= 0.0_wp .or. r_value > r_data + 1.0e-9_wp) cycle r_geom = radius*(r_value/r_max) x_label = center_x + r_geom*cos(angle) if (text_backend) x_label = x_label - radius*TEXT_LABEL_X_SHIFT y_label = center_y + r_geom*sin(angle) call backend%text(x_label, y_label, trim(labels(i))) end do end subroutine render_polar_radial_ticks subroutine render_polar_data(backend, theta, r, n, center_x, center_y, & r_scale, theta_offset, clockwise, color) !! Render polar data as connected line segments class(plot_context), intent(inout) :: backend real(wp), contiguous, intent(in) :: theta(:), r(:) integer, intent(in) :: n real(wp), intent(in) :: center_x, center_y, r_scale real(wp), intent(in) :: theta_offset logical, intent(in) :: clockwise real(wp), intent(in), optional :: color(3) real(wp) :: x1, y1, x2, y2, r_scaled real(wp) :: c(3) integer :: i if (n < 2) return c = [0.0_wp, 0.447_wp, 0.698_wp] ! Default blue if (present(color)) c = color call backend%color(c(1), c(2), c(3)) call backend%set_line_width(1.5_wp) call backend%set_line_style('-') r_scaled = r(1)*r_scale call polar_to_cartesian(theta(1), r_scaled, x1, y1, theta_offset, & clockwise) x1 = center_x + x1 y1 = center_y + y1 do i = 2, n r_scaled = r(i)*r_scale call polar_to_cartesian(theta(i), r_scaled, x2, y2, theta_offset, & clockwise) x2 = center_x + x2 y2 = center_y + y2 call backend%line(x1, y1, x2, y2) x1 = x2 y1 = y2 end do end subroutine render_polar_data end module fortplot_polar_rendering