diff --git a/BACKLOG.md b/BACKLOG.md index e345a38f..9719bc8d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -19,6 +19,9 @@ **Infrastructure & Documentation Issues (Lower Priority)** - [ ] #323: Test - add edge case tests for PDF heatmap color validation - [ ] #324: Refactor - define epsilon constant for numerical comparisons +- [ ] #342: Refactor - complete symlog tick generation implementation +- [ ] #343: Refactor - extract label positioning constants +- [ ] #344: Refactor - add format threshold constants in axes module ## DOING (Current Work) - [ ] #335: Fix - Axes wrong and no labels visible on scale_examples.html diff --git a/src/fortplot_ascii.f90 b/src/fortplot_ascii.f90 index 03ab452e..7f014c53 100644 --- a/src/fortplot_ascii.f90 +++ b/src/fortplot_ascii.f90 @@ -309,6 +309,11 @@ subroutine output_to_terminal(this) if (allocated(this%xlabel_text)) then call print_centered_title(this%xlabel_text, this%plot_width) end if + + ! Print ylabel (simple horizontal placement for now) + if (allocated(this%ylabel_text)) then + print '(A)', this%ylabel_text + end if end subroutine output_to_terminal subroutine output_to_file(this, unit) @@ -339,6 +344,11 @@ subroutine output_to_file(this, unit) if (allocated(this%xlabel_text)) then call write_centered_title(unit, this%xlabel_text, this%plot_width) end if + + ! Write ylabel to the left side of the plot if present + if (allocated(this%ylabel_text)) then + write(unit, '(A)') this%ylabel_text + end if end subroutine output_to_file integer function get_char_density(char) @@ -871,6 +881,7 @@ subroutine ascii_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, & title, xlabel, ylabel, & z_min, z_max, has_3d_plots) !! Draw axes and labels for ASCII backend + use fortplot_axes, only: compute_scale_ticks, format_tick_label, MAX_TICKS class(ascii_context), intent(inout) :: this character(len=*), intent(in) :: xscale, yscale real(wp), intent(in) :: symlog_threshold @@ -880,6 +891,10 @@ subroutine ascii_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, & logical, intent(in) :: has_3d_plots real(wp) :: label_x, label_y + real(wp) :: x_tick_positions(MAX_TICKS), y_tick_positions(MAX_TICKS) + integer :: num_x_ticks, num_y_ticks, i + character(len=50) :: tick_label + real(wp) :: tick_x, tick_y ! ASCII backend: explicitly set title and draw simple axes if (present(title)) then @@ -892,6 +907,24 @@ subroutine ascii_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, & call this%line(x_min, y_min, x_max, y_min) call this%line(x_min, y_min, x_min, y_max) + ! Generate tick marks and labels for ASCII + ! X-axis ticks (drawn as characters along bottom axis) + call compute_scale_ticks(xscale, x_min, x_max, symlog_threshold, x_tick_positions, num_x_ticks) + do i = 1, num_x_ticks + tick_x = x_tick_positions(i) + ! For ASCII, draw tick marks as characters in the text output + tick_label = format_tick_label(tick_x, xscale) + call this%text(tick_x, y_min - 0.05_wp * (y_max - y_min), trim(tick_label)) + end do + + ! Y-axis ticks (drawn as characters along left axis) + call compute_scale_ticks(yscale, y_min, y_max, symlog_threshold, y_tick_positions, num_y_ticks) + do i = 1, num_y_ticks + tick_y = y_tick_positions(i) + tick_label = format_tick_label(tick_y, yscale) + call this%text(x_min - 0.1_wp * (x_max - x_min), tick_y, trim(tick_label)) + end do + ! Store xlabel and ylabel for rendering outside the plot frame if (present(xlabel)) then if (allocated(xlabel)) then diff --git a/src/fortplot_axes.f90 b/src/fortplot_axes.f90 index 9c27e534..a4d557a7 100644 --- a/src/fortplot_axes.f90 +++ b/src/fortplot_axes.f90 @@ -11,7 +11,7 @@ module fortplot_axes implicit none private - public :: compute_scale_ticks, format_tick_label + public :: compute_scale_ticks, format_tick_label, MAX_TICKS integer, parameter :: MAX_TICKS = 20 @@ -141,14 +141,33 @@ function format_tick_label(value, scale_type) result(label) real(wp), intent(in) :: value character(len=*), intent(in) :: scale_type character(len=20) :: label + real(wp) :: abs_value - if (abs(value) < 1.0e-10_wp) then + abs_value = abs(value) + + if (abs_value < 1.0e-10_wp) then label = '0' else if (trim(scale_type) == 'log' .and. is_power_of_ten(value)) then label = format_power_of_ten(value) + else if (abs_value >= 1000.0_wp .or. abs_value < 0.01_wp) then + ! Use scientific notation for very large or very small values + write(label, '(ES10.2)') value + label = adjustl(label) + else if (abs_value >= 100.0_wp) then + ! No decimal places for values >= 100 + write(label, '(F0.0)') value + else if (abs_value >= 10.0_wp) then + ! One decimal place for values >= 10 + write(label, '(F0.1)') value + else if (abs_value >= 1.0_wp) then + ! Two decimal places for values >= 1 + write(label, '(F0.2)') value else - write(label, '(G0)') value + ! Three decimal places for small values + write(label, '(F0.3)') value end if + + label = adjustl(label) end function format_tick_label diff --git a/src/fortplot_figure_core.f90 b/src/fortplot_figure_core.f90 index df3ff2f6..5b6dbf7f 100644 --- a/src/fortplot_figure_core.f90 +++ b/src/fortplot_figure_core.f90 @@ -934,12 +934,10 @@ subroutine render_figure_axes(self) self%title, self%xlabel, self%ylabel, & 0.0_wp, 0.0_wp, .false.) type is (ascii_context) - ! ASCII backend: explicitly set title and draw simple axes - if (allocated(self%title)) then - call backend%set_title(self%title) - end if - call self%backend%line(self%x_min, self%y_min, self%x_max, self%y_min) - call self%backend%line(self%x_min, self%y_min, self%x_min, self%y_max) + call backend%draw_axes_and_labels_backend(self%xscale, self%yscale, self%symlog_threshold, & + self%x_min, self%x_max, self%y_min, self%y_max, & + self%title, self%xlabel, self%ylabel, & + 0.0_wp, 0.0_wp, .false.) class default ! For other backends, use simple axes call self%backend%line(self%x_min, self%y_min, self%x_max, self%y_min) diff --git a/src/fortplot_raster.f90 b/src/fortplot_raster.f90 index c0da4468..2f6e5e28 100644 --- a/src/fortplot_raster.f90 +++ b/src/fortplot_raster.f90 @@ -946,6 +946,8 @@ subroutine raster_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, & title, xlabel, ylabel, & z_min, z_max, has_3d_plots) !! Draw axes and labels for raster backends + use fortplot_axes, only: compute_scale_ticks, format_tick_label, MAX_TICKS + use fortplot_text, only: calculate_text_width, calculate_text_height class(raster_context), intent(inout) :: this character(len=*), intent(in) :: xscale, yscale real(wp), intent(in) :: symlog_threshold @@ -955,14 +957,84 @@ subroutine raster_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, & logical, intent(in) :: has_3d_plots real(wp) :: label_x, label_y + real(wp) :: x_tick_positions(MAX_TICKS), y_tick_positions(MAX_TICKS) + integer :: num_x_ticks, num_y_ticks, i + character(len=50) :: tick_label + real(wp) :: tick_x, tick_y + integer :: tick_length ! Tick length in pixels + integer :: px, py, text_width, text_height + real(wp) :: line_r, line_g, line_b + integer(1) :: text_r, text_g, text_b + real(wp) :: dummy_pattern(1) ! Dummy pattern for solid lines + real(wp) :: pattern_dist ! Pattern distance (mutable) + character(len=500) :: processed_text, escaped_text + integer :: processed_len ! Set color to black for axes and text call this%color(0.0_wp, 0.0_wp, 0.0_wp) + line_r = 0.0_wp; line_g = 0.0_wp; line_b = 0.0_wp ! Black color for lines + text_r = 0; text_g = 0; text_b = 0 ! Black color for text ! Draw axes call this%line(x_min, y_min, x_max, y_min) call this%line(x_min, y_min, x_min, y_max) + ! Generate and draw tick marks and labels + tick_length = 5 ! Tick length in pixels + + ! X-axis ticks + call compute_scale_ticks(xscale, x_min, x_max, symlog_threshold, x_tick_positions, num_x_ticks) + do i = 1, num_x_ticks + tick_x = x_tick_positions(i) + ! Transform tick position to pixel coordinates + px = int((tick_x - x_min) / (x_max - x_min) * real(this%plot_area%width, wp) + real(this%plot_area%left, wp)) + py = this%plot_area%bottom + this%plot_area%height ! Bottom of plot area + + ! Draw tick mark down from axis + dummy_pattern = 0.0_wp + pattern_dist = 0.0_wp + call draw_styled_line(this%raster%image_data, this%width, this%height, & + real(px, wp), real(py, wp), real(px, wp), real(py + tick_length, wp), & + line_r, line_g, line_b, 1.0_wp, 'solid', dummy_pattern, 0, 0.0_wp, pattern_dist) + + ! Draw tick label below tick mark + tick_label = format_tick_label(tick_x, xscale) + call process_latex_in_text(trim(tick_label), processed_text, processed_len) + call escape_unicode_for_raster(processed_text(1:processed_len), escaped_text) + text_width = calculate_text_width(trim(escaped_text)) + ! Center the label horizontally under the tick + call render_text_to_image(this%raster%image_data, this%width, this%height, & + px - text_width/2, py + tick_length + 5, trim(escaped_text), text_r, text_g, text_b) + end do + + ! Y-axis ticks + call compute_scale_ticks(yscale, y_min, y_max, symlog_threshold, y_tick_positions, num_y_ticks) + do i = 1, num_y_ticks + tick_y = y_tick_positions(i) + ! Transform tick position to pixel coordinates + px = this%plot_area%left ! Left edge of plot area + py = int(real(this%plot_area%bottom + this%plot_area%height, wp) - & + (tick_y - y_min) / (y_max - y_min) * real(this%plot_area%height, wp)) + + ! Draw tick mark to the left from axis + dummy_pattern = 0.0_wp + pattern_dist = 0.0_wp + call draw_styled_line(this%raster%image_data, this%width, this%height, & + real(px - tick_length, wp), real(py, wp), real(px, wp), real(py, wp), & + line_r, line_g, line_b, 1.0_wp, 'solid', dummy_pattern, 0, 0.0_wp, pattern_dist) + + ! Draw tick label to the left of tick mark + tick_label = format_tick_label(tick_y, yscale) + call process_latex_in_text(trim(tick_label), processed_text, processed_len) + call escape_unicode_for_raster(processed_text(1:processed_len), escaped_text) + text_width = calculate_text_width(trim(escaped_text)) + text_height = calculate_text_height(trim(escaped_text)) + ! Right-align the label to the left of the tick + call render_text_to_image(this%raster%image_data, this%width, this%height, & + px - tick_length - text_width - 5, py - text_height/2, & + trim(escaped_text), text_r, text_g, text_b) + end do + ! Draw title at top if present if (present(title)) then if (allocated(title)) then @@ -972,23 +1044,32 @@ subroutine raster_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, & end if end if - ! Draw xlabel centered below x-axis + ! Draw xlabel centered below x-axis (below tick labels) if (present(xlabel)) then if (allocated(xlabel)) then - label_x = (x_min + x_max) / 2.0_wp - label_y = y_min - 0.05_wp * (y_max - y_min) - call this%text(label_x, label_y, xlabel) + call process_latex_in_text(xlabel, processed_text, processed_len) + call escape_unicode_for_raster(processed_text(1:processed_len), escaped_text) + text_width = calculate_text_width(trim(escaped_text)) + ! Center horizontally in plot area, position below tick labels + px = this%plot_area%left + this%plot_area%width / 2 - text_width / 2 + py = this%plot_area%bottom + this%plot_area%height + 30 ! 30 pixels below plot area + call render_text_to_image(this%raster%image_data, this%width, this%height, & + px, py, trim(escaped_text), text_r, text_g, text_b) end if end if - ! Draw ylabel to the left of y-axis + ! Draw ylabel to the left of y-axis (rotated would be better, but for now horizontal) if (present(ylabel)) then if (allocated(ylabel)) then - ! For raster, ylabel needs special handling for rotation - ! For now, just place it horizontally - label_x = x_min - 0.1_wp * (x_max - x_min) - label_y = (y_min + y_max) / 2.0_wp - call this%text(label_x, label_y, ylabel) + call process_latex_in_text(ylabel, processed_text, processed_len) + call escape_unicode_for_raster(processed_text(1:processed_len), escaped_text) + text_width = calculate_text_width(trim(escaped_text)) + text_height = calculate_text_height(trim(escaped_text)) + ! Position to the left of plot area, centered vertically + px = 10 ! 10 pixels from left edge + py = this%plot_area%bottom + this%plot_area%height / 2 - text_height / 2 + call render_text_to_image(this%raster%image_data, this%width, this%height, & + px, py, trim(escaped_text), text_r, text_g, text_b) end if end if end subroutine raster_draw_axes_and_labels diff --git a/test/test_axes_labels_comprehensive.f90 b/test/test_axes_labels_comprehensive.f90 new file mode 100644 index 00000000..0c1b8ec7 --- /dev/null +++ b/test/test_axes_labels_comprehensive.f90 @@ -0,0 +1,54 @@ +program test_axes_labels_comprehensive + !! Comprehensive test for Issue #335: Missing axis labels and tick marks + !! Tests that all axis text elements are properly rendered in both PNG and ASCII + + use fortplot + implicit none + + real(wp), dimension(6) :: x_data, y_linear, y_log + integer :: i + + print *, "=== Comprehensive Axes Labels Test ===" + + ! Generate test data + x_data = [(real(i, wp), i = 1, 6)] + y_linear = x_data * 2.0_wp + 1.0_wp ! Linear: 3, 5, 7, 9, 11, 13 + y_log = exp(x_data * 0.5_wp) ! Exponential for log scale + + ! Test 1: Linear scale with all axis elements + print *, "Testing linear scale with comprehensive axis labeling..." + call figure() + call plot(x_data, y_linear) + call title('Linear Scale Test - All Elements') + call xlabel('Input Values (x)') + call ylabel('Linear Response (2x+1)') + call savefig('test_linear_axes_comprehensive.png') + call savefig('test_linear_axes_comprehensive.txt') + + ! Test 2: Log scale with proper formatting + print *, "Testing log scale with scientific notation..." + call figure() + call plot(x_data, y_log) + call set_yscale('log') + call title('Log Scale Test - Scientific Labels') + call xlabel('Input Index') + call ylabel('Exponential Growth') + call savefig('test_log_axes_comprehensive.png') + call savefig('test_log_axes_comprehensive.txt') + + print *, "" + print *, "VERIFICATION CHECKLIST:" + print *, "========================" + print *, "For each output file, verify:" + print *, "1. Title text is visible at top" + print *, "2. X-axis label appears below plot" + print *, "3. Y-axis label appears left of plot" + print *, "4. Tick marks are visible on axes" + print *, "5. Tick labels show numerical values" + print *, "6. Log scale uses appropriate notation" + print *, "" + print *, "Files created:" + print *, "- test_linear_axes_comprehensive.png/txt" + print *, "- test_log_axes_comprehensive.png/txt" + +end program test_axes_labels_comprehensive \ No newline at end of file diff --git a/test/test_axis_labels_rendering.f90 b/test/test_axis_labels_rendering.f90 new file mode 100644 index 00000000..883a3f4a --- /dev/null +++ b/test/test_axis_labels_rendering.f90 @@ -0,0 +1,206 @@ +program test_axis_labels_rendering + !! Comprehensive test for axis label rendering (Issue #335) + !! Verifies that axis tick labels, axis labels (xlabel/ylabel), and tick marks + !! are properly rendered in both PNG and ASCII backends + + use fortplot + use fortplot_text, only: calculate_text_width + implicit none + + logical :: test_passed + character(len=200) :: error_msg + + test_passed = .true. + + print *, "=== Testing Axis Labels Rendering (Issue #335) ===" + + ! Test 1: Linear scale with all labels + call test_linear_scale_labels(test_passed, error_msg) + if (.not. test_passed) then + print *, "FAILED: Linear scale test - ", trim(error_msg) + stop 1 + end if + print *, "PASSED: Linear scale with labels" + + ! Test 2: Log scale with power-of-ten formatting + call test_log_scale_labels(test_passed, error_msg) + if (.not. test_passed) then + print *, "FAILED: Log scale test - ", trim(error_msg) + stop 1 + end if + print *, "PASSED: Log scale with labels" + + ! Test 3: Symlog scale with mixed formatting + call test_symlog_scale_labels(test_passed, error_msg) + if (.not. test_passed) then + print *, "FAILED: Symlog scale test - ", trim(error_msg) + stop 1 + end if + print *, "PASSED: Symlog scale with labels" + + ! Test 4: Verify tick label formatting + call test_tick_label_formatting(test_passed, error_msg) + if (.not. test_passed) then + print *, "FAILED: Tick label formatting - ", trim(error_msg) + stop 1 + end if + print *, "PASSED: Tick label formatting" + + print *, "" + print *, "=== ALL AXIS LABEL TESTS PASSED ===" + +contains + + subroutine test_linear_scale_labels(passed, msg) + logical, intent(out) :: passed + character(len=*), intent(out) :: msg + + real(wp), dimension(10) :: x, y + integer :: i + + ! Generate test data + x = [(real(i, wp), i=1, 10)] + y = x**2 + + ! Create plot with all label types + call figure() + call plot(x, y) + call title('Linear Scale Test') + call xlabel('X Values') + call ylabel('Y = X²') + call savefig('test_linear_labels.png') + call savefig('test_linear_labels.txt') + + ! Basic check that files were created + passed = .true. + msg = '' + + ! Verify PNG was created (actual pixel verification would require image reading) + inquire(file='test_linear_labels.png', exist=passed) + if (.not. passed) then + msg = 'PNG file not created' + return + end if + + ! Verify ASCII was created + inquire(file='test_linear_labels.txt', exist=passed) + if (.not. passed) then + msg = 'ASCII file not created' + return + end if + + end subroutine test_linear_scale_labels + + subroutine test_log_scale_labels(passed, msg) + logical, intent(out) :: passed + character(len=*), intent(out) :: msg + + real(wp), dimension(50) :: x, y + integer :: i + + ! Generate exponential data + x = [(real(i, wp), i=1, 50)] + y = exp(x * 0.1_wp) + + ! Create log scale plot + call figure() + call plot(x, y) + call set_yscale('log') + call title('Log Scale Test') + call xlabel('Linear X') + call ylabel('Exponential Y (log scale)') + call savefig('test_log_labels.png') + call savefig('test_log_labels.txt') + + passed = .true. + msg = '' + + ! Verify files were created + inquire(file='test_log_labels.png', exist=passed) + if (.not. passed) then + msg = 'Log scale PNG not created' + return + end if + + end subroutine test_log_scale_labels + + subroutine test_symlog_scale_labels(passed, msg) + logical, intent(out) :: passed + character(len=*), intent(out) :: msg + + real(wp), dimension(50) :: x, y + integer :: i + real(wp), parameter :: threshold = 10.0_wp + + ! Generate data that crosses zero + x = [(real(i, wp) - 25.0_wp, i=1, 50)] + y = x**3 - 100.0_wp * x + + ! Create symlog scale plot + call figure() + call plot(x, y) + call set_yscale('symlog', threshold) + call title('Symlog Scale Test') + call xlabel('X Values') + call ylabel('Y = X³ - 100X') + call savefig('test_symlog_labels.png') + call savefig('test_symlog_labels.txt') + + passed = .true. + msg = '' + + ! Verify files were created + inquire(file='test_symlog_labels.png', exist=passed) + if (.not. passed) then + msg = 'Symlog scale PNG not created' + return + end if + + end subroutine test_symlog_scale_labels + + subroutine test_tick_label_formatting(passed, msg) + !! Test that tick labels are properly formatted + use fortplot_axes, only: format_tick_label + + logical, intent(out) :: passed + character(len=*), intent(out) :: msg + character(len=20) :: label + + passed = .true. + msg = '' + + ! Test linear scale formatting + label = format_tick_label(1.0_wp, 'linear') + if (len_trim(label) > 10) then + passed = .false. + msg = 'Linear tick label too long: ' // trim(label) + return + end if + + label = format_tick_label(1000.0_wp, 'linear') + ! Large values should use scientific notation (E notation) or be concise + if (len_trim(label) > 12) then + passed = .false. + msg = 'Large value formatting too long: ' // trim(label) + return + end if + + ! Test log scale formatting for powers of 10 + label = format_tick_label(100.0_wp, 'log') + if (index(label, '10^') == 0) then + passed = .false. + msg = 'Log scale power of 10 not formatted correctly: ' // trim(label) + return + end if + + ! Test small value formatting + label = format_tick_label(0.001_wp, 'linear') + if (len_trim(label) > 12) then + passed = .false. + msg = 'Small value formatting too long: ' // trim(label) + return + end if + + end subroutine test_tick_label_formatting + +end program test_axis_labels_rendering \ No newline at end of file