fortplot_ascii.f90 Source File


Source Code

module fortplot_ascii
    !! ASCII terminal plotting backend

    use fortplot_context, only: plot_context, setup_canvas
    use fortplot_constants, only: ASCII_CHAR_ASPECT
    use fortplot_margins, only: plot_margins_t, plot_area_t, calculate_plot_area
    use fortplot_ascii_mathtext, only: sanitize_ascii_text
    use fortplot_ascii_utils, only: text_element_t
    use fortplot_ascii_elements, only: draw_ascii_marker, fill_ascii_heatmap, &
                                       draw_ascii_arrow, draw_ascii_vector_arrow
    use fortplot_ascii_drawing, only: draw_ascii_stream_segment, &
                                      normalize_text_charset, charset_is_unicode
    use fortplot_ascii_legend, only: ascii_render_legend_impl, ascii_calc_legend_dims_impl, &
                                     ascii_set_legend_border_impl, ascii_calc_legend_pos_impl, &
                                     ascii_add_legend_entry_impl, ascii_clear_legend_impl, &
                                     ascii_clear_pie_legend_impl, ascii_register_pie_legend_impl, &
                                     append_ascii_legend_line_helper, decode_ascii_legend_line
    use fortplot_ascii_backend_ops, only: ascii_extract_rgb_impl, ascii_get_png_impl, &
                                          ascii_prepare_3d_impl, ascii_render_ylabel_impl, &
                                          ascii_draw_axes_impl, ascii_save_coord_impl, &
                                          ascii_set_coord_impl, ascii_render_axes_impl
    use fortplot_ascii_rendering, only: ascii_finalize => ascii_finalize, &
                                        ascii_get_output, output_to_file, &
                                        braille_finalize, braille_output_to_file
    use fortplot_ascii_primitives, only: ascii_draw_line_primitive, &
                                         ascii_fill_quad_primitive
    use fortplot_braille, only: braille_canvas_t, create_braille_canvas, &
                                braille_draw_line, set_braille_pixel
    use fortplot_text_color, only: normalize_text_color_mode, COLOR_NONE, &
                                   resolve_text_color_mode_file
    use fortplot_3d_axes, only: draw_3d_axes
    use, intrinsic :: iso_fortran_env, only: wp => real64
    implicit none

    private
    public :: ascii_context, create_ascii_canvas, ASCII_CHAR_ASPECT

    !! Upper bound on tick labels one axis can request; four axes (primary and
    !! secondary x/y) size the text-element headroom for twin plots.
    integer, parameter :: MAX_ASCII_TICKS = 20

    type, extends(plot_context) :: ascii_context
        character(len=1), allocatable :: canvas(:, :)
        type(plot_margins_t) :: margins
        type(plot_area_t) :: plot_area
        character(len=:), allocatable :: title_text
        character(len=:), allocatable :: xlabel_text
        character(len=:), allocatable :: ylabel_text
        logical :: title_set = .false.  ! Track if title was explicitly set
        type(text_element_t), allocatable :: text_elements(:)
        integer :: num_text_elements = 0
        type(text_element_t), allocatable :: arrow_elements(:)
        integer :: num_arrow_elements = 0
        real(wp) :: current_r, current_g, current_b
        logical :: stream_mode = .false.
        integer :: plot_width = 80
        integer :: plot_height = 24
        character(len=96), allocatable :: legend_lines(:)
        integer :: num_legend_lines = 0
        logical :: capturing_legend = .false.
        real(wp) :: stored_y_min = 0.0_wp
        real(wp) :: stored_y_max = 0.0_wp
        logical :: has_stored_y_range = .false.
        character(len=16) :: last_xscale = 'linear'
        character(len=16) :: last_yscale = 'linear'
        real(wp) :: last_symlog_threshold = 1.0_wp
        !! Optional custom x-tick positions/labels forwarded by the render
        !! engine so the ASCII axis honours ``set_xticks`` (issue #1714).
        real(wp), allocatable :: custom_xtick_positions(:)
        character(len=64), allocatable :: custom_xtick_labels(:)
        !! Text charset: 'ascii' (default, pure-ASCII bytes), 'unicode'
        !! (box-drawing frame plus Unicode markers/arrows, #2060), or 'braille'
        !! (2x4 subpixel line/marker data via ``braille`` while axes and labels
        !! stay text, #2061). 'auto' resolves from the environment.
        character(len=16) :: text_charset = 'ascii'
        !! Text color mode: 'never' (default, no escapes), 'ansi16', 'ansi256',
        !! 'truecolor', or 'auto' (resolved from the environment at output time).
        !! ``canvas_color`` records the packed RGB of each plotted data cell so
        !! ANSI SGR spans can be emitted at output assembly (#2062).
        character(len=16) :: text_color_mode = 'never'
        integer, allocatable :: canvas_color(:, :)
        type(braille_canvas_t), allocatable :: braille
    contains
        procedure :: line => ascii_draw_line
        procedure :: color => ascii_set_color
        procedure :: text => ascii_draw_text
        procedure :: clear_text_background => ascii_clear_text_background
        procedure :: draw_text_overlay => ascii_draw_text_overlay
        procedure :: set_line_width => ascii_set_line_width
        procedure :: set_line_style => ascii_set_line_style
        procedure :: save => ascii_save
        procedure :: save_to_unit => ascii_save_to_unit
        procedure :: set_title => ascii_set_title
        procedure :: draw_marker => ascii_draw_marker
        procedure :: set_marker_colors => ascii_set_marker_colors
        procedure :: set_marker_colors_with_alpha => ascii_set_marker_colors_with_alpha
        procedure :: fill_heatmap => ascii_fill_heatmap
        procedure :: draw_arrow => ascii_draw_arrow
        procedure :: draw_arrowhead => ascii_draw_arrowhead
        procedure :: draw_quiver_arrow => ascii_draw_quiver_arrow
        procedure :: get_ascii_output => ascii_get_output_method
        procedure :: set_text_charset => ascii_set_text_charset
        procedure :: set_text_color_mode => ascii_set_text_color_mode

        !! New polymorphic methods to eliminate SELECT TYPE
        procedure :: get_width_scale => ascii_get_width_scale
        procedure :: get_height_scale => ascii_get_height_scale
        procedure :: fill_quad => ascii_fill_quad
        procedure :: render_legend_specialized => ascii_render_legend_specialized
        procedure :: calculate_legend_dimensions => ascii_calculate_legend_dimensions
        procedure :: set_legend_border_width => ascii_set_legend_border_width
        procedure :: calculate_legend_position_backend => &
            ascii_calculate_legend_position
        procedure :: extract_rgb_data => ascii_extract_rgb_data
        procedure :: get_png_data_backend => ascii_get_png_data
        procedure :: prepare_3d_data => ascii_prepare_3d_data
        procedure :: render_ylabel => ascii_render_ylabel
        procedure :: draw_axes_and_labels_backend => ascii_draw_axes_and_labels
        procedure :: save_coordinates => ascii_save_coordinates
        procedure :: set_coordinates => ascii_set_coordinates
        procedure :: render_axes => ascii_render_axes
        procedure :: clear_ascii_legend => ascii_clear_legend_lines
        procedure :: add_ascii_legend_entry => ascii_add_legend_entry
        procedure :: clear_pie_legend_entries => ascii_clear_pie_legend_entries
        procedure :: register_pie_legend_entry => ascii_register_pie_legend_entry
    end type ascii_context

  
contains

    function create_ascii_canvas(width, height) result(ctx)
        integer, intent(in), optional :: width, height
        type(ascii_context) :: ctx
        integer :: w, h

        w = 80
        if (present(width)) then
            if (width > 0) then
                w = merge(max(80, min(120, nint(real(width, wp) / 10.0_wp))), width, width > 200)
            end if
        end if
        h = 24
        if (present(height)) then
            if (height > 0) then
                h = merge(max(20, min(30, nint(real(height, wp) / 20.0_wp))), height, height > 60)
            end if
        end if

        call setup_canvas(ctx, w, h)
        ctx%plot_width = w
        ctx%plot_height = h
        ctx%margins%left = 0.0_wp
        ctx%margins%right = 0.0_wp
        ctx%margins%bottom = 0.0_wp
        ctx%margins%top = 0.0_wp
        call calculate_plot_area(w, h, ctx%margins, ctx%plot_area)
        allocate (ctx%canvas(h, w))
        ctx%canvas = ' '
        allocate (ctx%canvas_color(h, w))
        ctx%canvas_color = COLOR_NONE
        allocate (ctx%text_elements(20))
        ctx%num_text_elements = 0
        allocate (ctx%arrow_elements(1000))
        ctx%num_arrow_elements = 0
        ctx%title_set = .false.
        allocate (ctx%legend_lines(0))
        ctx%num_legend_lines = 0
        ctx%capturing_legend = .false.
        ctx%current_r = 0.0_wp
        ctx%current_g = 0.0_wp
        ctx%current_b = 1.0_wp
    end function create_ascii_canvas

    subroutine ascii_draw_line(this, x1, y1, x2, y2)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x1, y1, x2, y2
        integer :: x0s, y0s, x1s, y1s
        logical :: ok0, ok1

        if (ascii_braille_active(this)) then
            call ascii_data_to_subpixel(this, x1, y1, x0s, y0s, ok0)
            call ascii_data_to_subpixel(this, x2, y2, x1s, y1s, ok1)
            if (ok0 .and. ok1) call braille_draw_line(this%braille, x0s, y0s, x1s, y1s)
            return
        end if

        if (this%stream_mode) then
            call draw_ascii_stream_segment(this%canvas, x1, y1, x2, y2, &
                                           this%x_min, this%x_max, &
                                           this%y_min, this%y_max, this%plot_area, &
                                           this%plot_width, this%plot_height)
            return
        end if

        call ascii_draw_line_primitive(this%canvas, x1, y1, x2, y2, &
                                       this%x_min, this%x_max, this%y_min, this%y_max, &
                                       this%plot_area, this%plot_width, this%plot_height, &
                                       this%current_r, this%current_g, this%current_b, &
                                       this%canvas_color)
    end subroutine ascii_draw_line

    subroutine ascii_set_color(this, r, g, b)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: r, g, b

        this%current_r = r
        this%current_g = g
        this%current_b = b
    end subroutine ascii_set_color

   subroutine ascii_set_line_width(this, width)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: width
        associate(u => this%width, v => width); end associate
    end subroutine ascii_set_line_width

    subroutine ascii_set_line_style(this, style)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: style
        associate(u => this%width, v => style); end associate
    end subroutine ascii_set_line_style

    subroutine ascii_draw_text(this, x, y, text)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y
        character(len=*), intent(in) :: text

        character(len=500) :: processed_text
        integer :: processed_len, text_x, text_y, pw, ph
        logical :: screen_coords, use_plot_area

        call ensure_text_capacity(this, this%num_text_elements + 1)
        call sanitize_ascii_text(text, processed_text, processed_len)

        pw = this%plot_width
        ph = this%plot_height
        use_plot_area = this%plot_area%width > 0 .and. this%plot_area%height > 0
        screen_coords = .false.
        if (this%x_max > this%x_min .and. this%y_max > this%y_min .and. &
            x >= this%x_min .and. x <= this%x_max .and. &
            y >= this%y_min .and. y <= this%y_max) then
            if (use_plot_area) then
                text_x = this%plot_area%left + nint((x - this%x_min)/(this%x_max - this%x_min)* &
                         real(max(1, this%plot_area%width), wp))
                text_y = this%plot_area%bottom + this%plot_area%height - &
                         nint((y - this%y_min)/(this%y_max - this%y_min)* &
                         real(max(1, this%plot_area%height), wp))
            else
                text_x = nint((x - this%x_min)/(this%x_max - this%x_min)*real(pw, wp))
                text_y = nint((this%y_max - y)/(this%y_max - this%y_min)*real(ph, wp))
            end if
        else if (x >= 1.0_wp .and. x <= real(pw, wp) .and. &
                 y >= 1.0_wp .and. y <= real(ph, wp)) then
            text_x = nint(x)
            text_y = nint(y)
            screen_coords = .true.
        else
            text_x = nint(x)
            text_y = nint(y)
        end if

        if (screen_coords) then
            text_x = max(1, min(text_x, pw))
            text_y = max(1, min(text_y, ph))
        else if (use_plot_area) then
            text_x = max(this%plot_area%left + 1, &
                         min(text_x, this%plot_area%left + max(1, this%plot_area%width) - 1))
            text_y = max(this%plot_area%bottom + 1, &
                         min(text_y, this%plot_area%bottom + max(1, this%plot_area%height) - 1))
        else
            text_x = max(2, min(text_x, max(2, pw - processed_len - 1)))
            text_y = max(1, min(text_y, ph))
        end if

        this%num_text_elements = this%num_text_elements + 1
        this%text_elements(this%num_text_elements)%text = processed_text(1:processed_len)
        this%text_elements(this%num_text_elements)%x = text_x
        this%text_elements(this%num_text_elements)%y = text_y
        this%text_elements(this%num_text_elements)%color_r = this%current_r
        this%text_elements(this%num_text_elements)%color_g = this%current_g
        this%text_elements(this%num_text_elements)%color_b = this%current_b
    end subroutine ascii_draw_text

    subroutine ensure_text_capacity(this, needed)
        !! Grow the stored text-element buffer so tick and axis labels are never
        !! silently dropped. Twin (secondary) axes add a second set of tick
        !! labels, easily exceeding the original fixed capacity (issue #2066).
        class(ascii_context), intent(inout) :: this
        integer, intent(in) :: needed
        type(text_element_t), allocatable :: grown(:)
        integer :: cap

        if (.not. allocated(this%text_elements)) then
            allocate (this%text_elements(max(needed, 20)))
            return
        end if
        cap = size(this%text_elements)
        if (needed <= cap) return
        allocate (grown(max(needed, 2*cap)))
        if (this%num_text_elements > 0) then
            grown(1:this%num_text_elements) = &
                this%text_elements(1:this%num_text_elements)
        end if
        call move_alloc(grown, this%text_elements)
    end subroutine ensure_text_capacity

    subroutine ascii_clear_text_background(this, x, y, width)
        !! Blank a horizontal run of canvas cells so a text overlay (legend
        !! block) reads against clear background instead of the plotted curves
        !! and axis lines underneath it.
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y
        integer, intent(in) :: width

        integer :: col, row, first_col, last_col

        row = nint(y)
        if (row < 1 .or. row > this%plot_height) return

        first_col = max(2, nint(x))
        last_col = min(this%plot_width - 1, first_col + width - 1)
        do col = first_col, last_col
            this%canvas(row, col) = ' '
        end do
    end subroutine ascii_clear_text_background

    subroutine ascii_draw_text_overlay(this, x, y, text)
        !! Place overlay text (legend rows) directly at the given cell so stacked
        !! entries keep their own rows. The buffered text path shifts colliding
        !! rows to dodge earlier text, which blends adjacent legend labels when
        !! the block lands over tick labels; a direct write avoids that.
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y
        character(len=*), intent(in) :: text

        character(len=500) :: processed
        integer :: processed_len, row, col, k, j

        if (text_charset_is_unicode(this)) then
            if (.not. this%capturing_legend) then
                call ascii_clear_legend_impl(this%legend_lines, this%num_legend_lines, 'Legend:')
                this%capturing_legend = .true.
            end if
            call append_ascii_legend_line_helper(this%legend_lines, &
                                                this%num_legend_lines, text)
            return
        end if

        call sanitize_ascii_text(text, processed, processed_len)
        row = nint(y)
        if (row < 1 .or. row > this%plot_height) return

        col = nint(x)
        do k = 1, processed_len
            j = col + k - 1
            if (j >= 2 .and. j <= this%plot_width - 1) then
                this%canvas(row, j) = processed(k:k)
            end if
        end do
    end subroutine ascii_draw_text_overlay

   subroutine ascii_set_title(this, title)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: title
        character(len=500) :: processed_title
        integer :: processed_len

        call sanitize_ascii_text(title, processed_title, processed_len)
        this%title_text = processed_title(1:processed_len)
        this%title_set = .true.
    end subroutine ascii_set_title

    subroutine ascii_save(this, filename)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: filename

        if (ascii_braille_active(this)) then
            call braille_finalize(this%canvas, this%braille%mask, &
                                  this%text_elements, this%num_text_elements, &
                                  this%arrow_elements, this%num_arrow_elements, &
                                  this%plot_width, this%plot_height, &
                                  this%title_text, this%xlabel_text, this%ylabel_text, &
                                  this%legend_lines, this%num_legend_lines, filename)
            return
        end if

        call ascii_finalize(this%canvas, this%text_elements, this%num_text_elements, &
                            this%arrow_elements, this%num_arrow_elements, &
                            this%plot_width, this%plot_height, &
                            this%title_text, this%xlabel_text, this%ylabel_text, &
                            this%legend_lines, this%num_legend_lines, filename, &
                            text_charset=trim(this%text_charset), &
                            color_mode=trim(this%text_color_mode), &
                            canvas_color=this%canvas_color)
    end subroutine ascii_save

    subroutine ascii_save_to_unit(this, unit)
        class(ascii_context), intent(inout) :: this
        integer, intent(in) :: unit
        character(len=:), allocatable :: file_color

        if (ascii_braille_active(this)) then
            call braille_output_to_file(this%canvas, this%braille%mask, &
                                        this%text_elements, this%num_text_elements, &
                                        this%arrow_elements, this%num_arrow_elements, &
                                        this%plot_width, this%plot_height, &
                                        this%title_text, this%xlabel_text, this%ylabel_text, &
                                        this%legend_lines, this%num_legend_lines, unit)
            return
        end if

        file_color = resolve_text_color_mode_file(trim(this%text_color_mode))
        call output_to_file(this%canvas, this%text_elements, &
                            this%num_text_elements, &
                            this%arrow_elements, this%num_arrow_elements, &
                            this%plot_width, this%plot_height, &
                            this%title_text, this%xlabel_text, this%ylabel_text, &
                            this%legend_lines, this%num_legend_lines, unit, &
                            text_charset=trim(this%text_charset), &
                            color_mode=file_color, &
                            canvas_color=this%canvas_color)
    end subroutine ascii_save_to_unit

    subroutine ascii_draw_marker(this, x, y, style, size)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y
        character(len=*), intent(in) :: style
        real(wp), intent(in), optional :: size

        integer :: xs, ys
        logical :: ok

        ! ASCII markers are single glyphs; per-point size has no raster meaning.
        if (present(size)) then
            associate (unused => size); end associate
        end if

        if (ascii_braille_active(this)) then
            call ascii_data_to_subpixel(this, x, y, xs, ys, ok)
            if (ok) call set_braille_pixel(this%braille, xs, ys)
            return
        end if

        call draw_ascii_marker(this%canvas, x, y, style, &
                               this%x_min, this%x_max, this%y_min, this%y_max, &
                               this%plot_area, this%plot_width, this%plot_height)
    end subroutine ascii_draw_marker

subroutine ascii_set_marker_colors(this, edge_r, edge_g, edge_b, face_r, &
                                        face_g, face_b)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: edge_r, edge_g, edge_b
        real(wp), intent(in) :: face_r, face_g, face_b
        associate(u => this%width + edge_r + edge_g + edge_b + face_r + face_g + face_b); end associate
    end subroutine ascii_set_marker_colors

subroutine ascii_set_marker_colors_with_alpha(this, edge_r, edge_g, edge_b, &
                                                   edge_alpha, &
                                                   face_r, face_g, face_b, face_alpha)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: edge_r, edge_g, edge_b, edge_alpha
        real(wp), intent(in) :: face_r, face_g, face_b, face_alpha
        associate(u => this%width + edge_r + edge_g + edge_b + edge_alpha + &
                           face_r + face_g + face_b + face_alpha); end associate
    end subroutine ascii_set_marker_colors_with_alpha

subroutine ascii_fill_heatmap(this, x_grid, y_grid, z_grid, z_min, z_max, colormap_name)
        class(ascii_context), intent(inout) :: this
        real(wp), contiguous, intent(in) :: x_grid(:), y_grid(:), z_grid(:, :)
        real(wp), intent(in) :: z_min, z_max
        character(len=*), intent(in), optional :: colormap_name
        character(len=32) :: cmap

        cmap = 'viridis'
        if (present(colormap_name)) cmap = trim(colormap_name)
        call fill_ascii_heatmap(this%canvas, x_grid, y_grid, z_grid, z_min, z_max, &
                                this%x_min, this%x_max, this%y_min, this%y_max, &
                                this%plot_area, this%plot_width, this%plot_height, &
                                this%canvas_color, trim(cmap))
    end subroutine ascii_fill_heatmap

    subroutine ascii_draw_arrow(this, x, y, dx, dy, size, style)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y, dx, dy, size
        character(len=*), intent(in) :: style

        integer :: px, py, inner_width, inner_height
        character(len=1) :: arrow_char

        call draw_ascii_arrow(this%canvas, x, y, dx, dy, size, style, &
                              this%x_min, this%x_max, this%y_min, this%y_max, &
                              this%plot_area, this%width, this%height, &
                              this%has_rendered_arrows, this%uses_vector_arrows, &
                              this%has_triangular_arrows)

        if (this%num_arrow_elements < ubound(this%arrow_elements, 1)) then
            inner_width = max(1, this%plot_area%width - 2)
            inner_height = max(1, this%plot_area%height - 2)
            px = this%plot_area%left + 1 + nint((x - this%x_min)/(this%x_max - this%x_min)* &
                 real(inner_width, wp))
            py = this%plot_area%bottom + this%plot_area%height - 1 - &
                 nint((y - this%y_min)/(this%y_max - this%y_min)* &
                 real(inner_height, wp))
            if (px >= this%plot_area%left + 1 .and. px <= this%plot_area%left + this%plot_area%width - 1 .and. &
                py >= this%plot_area%bottom + 1 .and. py <= this%plot_area%bottom + this%plot_area%height - 1) then
                arrow_char = infer_ascii_arrow_char(dx, dy)
                this%num_arrow_elements = this%num_arrow_elements + 1
                this%arrow_elements(this%num_arrow_elements)%text = arrow_char
                this%arrow_elements(this%num_arrow_elements)%x = px
                this%arrow_elements(this%num_arrow_elements)%y = py
                this%arrow_elements(this%num_arrow_elements)%color_r = this%current_r
                this%arrow_elements(this%num_arrow_elements)%color_g = this%current_g
                this%arrow_elements(this%num_arrow_elements)%color_b = this%current_b
            end if
        end if
    end subroutine ascii_draw_arrow

    subroutine ascii_draw_arrowhead(this, x, y, dx, dy, size, style)
        !! ASCII has no separate head-only glyph; reuse draw_arrow.
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y, dx, dy, size
        character(len=*), intent(in) :: style

        call draw_ascii_arrow(this%canvas, x, y, dx, dy, size, style, &
                              this%x_min, this%x_max, this%y_min, this%y_max, &
                              this%plot_area, this%width, this%height, &
                              this%has_rendered_arrows, this%uses_vector_arrows, &
                              this%has_triangular_arrows)
    end subroutine ascii_draw_arrowhead

    subroutine ascii_draw_quiver_arrow(this, x, y, u, v)
        !! Stamp a quiver arrow directly onto the canvas via the axis-policy
        !! layer path so it clips to the plot interior and never corrupts axis
        !! spines, tick marks, or tick/axis labels (issue #2071).
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x, y, u, v

        call draw_ascii_vector_arrow(this%canvas, x, y, u, v, &
                                     this%x_min, this%x_max, this%y_min, this%y_max, &
                                     this%plot_area, this%plot_width, this%plot_height)
    end subroutine ascii_draw_quiver_arrow

    function ascii_get_output_method(this) result(output)
        class(ascii_context), intent(in) :: this
        character(len=:), allocatable :: output

        output = ascii_get_output(this%canvas, this%width, this%height)
    end function ascii_get_output_method

    pure character(len=1) function infer_ascii_arrow_char(dx, dy) result(arrow_char)
        real(wp), intent(in) :: dx, dy
        real(wp) :: angle

        angle = atan2(dy / ASCII_CHAR_ASPECT, dx)

        if (abs(angle) < 0.393_wp) then
            arrow_char = '>'
        else if (angle >= 0.393_wp .and. angle < 1.178_wp) then
            arrow_char = '/'
        else if (angle >= 1.178_wp .and. angle < 1.963_wp) then
            arrow_char = '^'
        else if (angle >= 1.963_wp .and. angle < 2.749_wp) then
            arrow_char = '\'
        else if (abs(angle) >= 2.749_wp) then
            arrow_char = '<'
        else if (angle <= -0.393_wp .and. angle > -1.178_wp) then
            arrow_char = '\'
        else if (angle <= -1.178_wp .and. angle > -1.963_wp) then
            arrow_char = 'v'
        else
            arrow_char = '/'
        end if
    end function infer_ascii_arrow_char

   function ascii_get_width_scale(this) result(scale)
        class(ascii_context), intent(in) :: this
        real(wp) :: scale

        if (this%plot_width > 0 .and. this%x_max > this%x_min) then
            scale = real(this%plot_width, wp)/(this%x_max - this%x_min)
        else
            scale = 1.0_wp
        end if
    end function ascii_get_width_scale

    function ascii_get_height_scale(this) result(scale)
        class(ascii_context), intent(in) :: this
        real(wp) :: scale

        if (this%plot_height > 0 .and. this%y_max > this%y_min) then
            scale = real(this%plot_height, wp)/(this%y_max - this%y_min)
        else
            scale = 1.0_wp
        end if
    end function ascii_get_height_scale

    subroutine ascii_fill_quad(this, x_quad, y_quad)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x_quad(4), y_quad(4)
        real(wp) :: y_lo, y_hi

        ! Map quad vertices through the same data y range as the axis frame and
        ! ticks. The stored range preserves compatibility with older saves.
        if (this%has_stored_y_range) then
            y_lo = this%stored_y_min
            y_hi = this%stored_y_max
        else
            y_lo = this%y_min
            y_hi = this%y_max
        end if

        call ascii_fill_quad_primitive(this%canvas, x_quad, y_quad, &
                                       this%x_min, this%x_max, y_lo, y_hi, &
                                       this%plot_area, this%plot_width, this%plot_height, &
                                       this%current_r, this%current_g, this%current_b, &
                                       this%canvas_color)
    end subroutine ascii_fill_quad

 subroutine ascii_render_legend_specialized(this, legend, legend_x, legend_y)
        use fortplot_legend, only: legend_t
        class(ascii_context), intent(inout) :: this
        type(legend_t), intent(in) :: legend
        real(wp), intent(in) :: legend_x, legend_y

        associate(unused_x => legend_x, unused_y => legend_y); end associate
        call ascii_render_legend_impl(legend, this%legend_lines, &
                                      this%num_legend_lines, &
                                      raw_labels=text_charset_is_unicode(this))
    end subroutine ascii_render_legend_specialized

 subroutine ascii_calculate_legend_dimensions(this, legend, legend_width, &
                                                  legend_height)
        use fortplot_legend, only: legend_t
        class(ascii_context), intent(in) :: this
        type(legend_t), intent(in) :: legend
        real(wp), intent(out) :: legend_width, legend_height

        call ascii_calc_legend_dims_impl(legend, this%width, legend_width, legend_height)
    end subroutine ascii_calculate_legend_dimensions

    subroutine ascii_set_legend_border_width(this)
        class(ascii_context), intent(inout) :: this
        call ascii_set_legend_border_impl()
    end subroutine ascii_set_legend_border_width

  subroutine ascii_calculate_legend_position(this, legend, x, y)
        use fortplot_legend, only: legend_t
        class(ascii_context), intent(in) :: this
        type(legend_t), intent(in) :: legend
        real(wp), intent(out) :: x, y
        real(wp) :: lw, lh

        call this%calculate_legend_dimensions(legend, lw, lh)
        call ascii_calc_legend_pos_impl(legend, this%width, this%height, lw, lh, x, y)
    end subroutine ascii_calculate_legend_position

    subroutine ascii_extract_rgb_data(this, width, height, rgb_data)
        class(ascii_context), intent(in) :: this
        integer, intent(in) :: width, height
        real(wp), intent(out) :: rgb_data(width, height, 3)

        associate(unused_w => this%width); end associate
        call ascii_extract_rgb_impl(width, height, rgb_data)
    end subroutine ascii_extract_rgb_data

   subroutine ascii_get_png_data(this, width, height, png_data, status)
        class(ascii_context), intent(in) :: this
        integer, intent(in) :: width, height
        integer(1), allocatable, intent(out) :: png_data(:)
        integer, intent(out) :: status

        associate(unused_w => this%width); end associate
        call ascii_get_png_impl(width, height, png_data, status)
    end subroutine ascii_get_png_data

   subroutine ascii_prepare_3d_data(this, plots)
        use fortplot_plot_data, only: plot_data_t
        class(ascii_context), intent(inout) :: this
        type(plot_data_t), intent(in) :: plots(:)

        associate(unused_w => this%width); end associate
        call ascii_prepare_3d_impl(plots)
    end subroutine ascii_prepare_3d_data

   subroutine ascii_render_ylabel(this, ylabel)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: ylabel

        associate(unused_w => this%width); end associate
        call ascii_render_ylabel_impl(ylabel)
    end subroutine ascii_render_ylabel

    subroutine ascii_draw_axes_and_labels(this, xscale, yscale, symlog_threshold, &
                                          x_min, x_max, y_min, y_max, &
                                          title, xlabel, ylabel, &
                                          x_date_format, y_date_format, &
                                          z_min, z_max, has_3d_plots)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(in) :: x_min, x_max, y_min, y_max
        character(len=:), allocatable, intent(in), optional :: title, xlabel, ylabel
        character(len=*), intent(in), optional :: x_date_format, y_date_format
        real(wp), intent(in), optional :: z_min, z_max
        logical, intent(in) :: has_3d_plots
        logical :: has_custom_ticks

        this%last_xscale = trim(xscale)
        this%last_yscale = trim(yscale)
        this%last_symlog_threshold = symlog_threshold

        has_custom_ticks = allocated(this%custom_xtick_positions) .and. &
                           allocated(this%custom_xtick_labels)
        call ensure_text_capacity(this, this%num_text_elements + 4*MAX_ASCII_TICKS)
        if (has_3d_plots) then
            call ascii_draw_projected_3d_axes(this, x_min, x_max, y_min, y_max, &
                                              z_min, z_max)
        end if
        call ascii_draw_axes_impl(this%canvas, xscale, yscale, symlog_threshold, &
                               x_min, x_max, y_min, y_max, &
                               title, xlabel, ylabel, x_date_format, y_date_format, &
                               z_min, z_max, has_3d_plots, &
                               this%current_r, this%current_g, this%current_b, &
                               this%plot_area, this%plot_width, this%plot_height, &
                               this%title_text, this%xlabel_text, this%ylabel_text, &
                               this%text_elements, this%num_text_elements, &
                               has_custom_ticks, &
                               this%custom_xtick_positions, this%custom_xtick_labels, &
                               unicode_labels=text_charset_is_unicode(this))
    end subroutine ascii_draw_axes_and_labels

    logical function text_charset_is_unicode(this) result(is_unicode)
        !! Resolve the context charset (honouring 'auto') to a Unicode flag used
        !! for label sanitization decisions during rendering (issue #2060).
        class(ascii_context), intent(in) :: this
        is_unicode = charset_is_unicode(normalize_text_charset(trim(this%text_charset)))
    end function text_charset_is_unicode

    subroutine ascii_draw_projected_3d_axes(this, x_min, x_max, y_min, y_max, &
                                            z_min, z_max)
        !! Render the projected 3D box, ticks, and tick labels for ASCII output.
        !! Replaces the static 2D axis frame so the ASCII axes rotate with the
        !! stored view angles, matching matplotlib mplot3d (refs #2054).
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x_min, x_max, y_min, y_max
        real(wp), intent(in), optional :: z_min, z_max

        real(wp) :: zlo, zhi

        zlo = 0.0_wp
        zhi = 1.0_wp
        if (present(z_min)) zlo = z_min
        if (present(z_max)) zhi = z_max

        call draw_3d_axes(this, x_min, x_max, y_min, y_max, zlo, zhi, &
                          fill_panes=.false., label_gap_px=22.0_wp)
    end subroutine ascii_draw_projected_3d_axes

    subroutine ascii_save_coordinates(this, x_min, x_max, y_min, y_max)
        class(ascii_context), intent(in) :: this
        real(wp), intent(out) :: x_min, x_max, y_min, y_max

        call ascii_save_coord_impl(this%x_min, this%x_max, this%y_min, this%y_max, &
                                    this%has_stored_y_range, this%stored_y_min, this%stored_y_max, &
                                    x_min, x_max, y_min, y_max)
    end subroutine ascii_save_coordinates

    subroutine ascii_set_coordinates(this, x_min, x_max, y_min, y_max)
        class(ascii_context), intent(inout) :: this
        real(wp), intent(in) :: x_min, x_max, y_min, y_max

        call ascii_set_coord_impl(x_min, x_max, y_min, y_max, &
                                   this%x_min, this%x_max, this%y_min, this%y_max, &
                                   this%stored_y_min, this%stored_y_max, this%has_stored_y_range)
    end subroutine ascii_set_coordinates

    subroutine ascii_render_axes(this, title_text, xlabel_text, ylabel_text)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in), optional :: title_text, xlabel_text, ylabel_text

        character(len=:), allocatable :: t, xl, yl

        t = ''; xl = ''; yl = ''
        if (present(title_text)) t = title_text
        if (present(xlabel_text)) xl = xlabel_text
        if (present(ylabel_text)) yl = ylabel_text

        this%title_text = t
        this%xlabel_text = xl
        this%ylabel_text = yl

        call ensure_text_capacity(this, this%num_text_elements + 4*MAX_ASCII_TICKS)
        call ascii_render_axes_impl(this%x_min, this%x_max, this%y_min, this%y_max, &
                                     this%has_stored_y_range, this%stored_y_min, this%stored_y_max, &
                                     this%last_xscale, this%last_yscale, this%last_symlog_threshold, &
                                     this%canvas, this%plot_area, this%plot_width, this%plot_height, &
                                     this%title_text, this%xlabel_text, this%ylabel_text, &
                                     this%text_elements, this%num_text_elements, &
                                     this%custom_xtick_positions, this%custom_xtick_labels, &
                                     unicode_labels=text_charset_is_unicode(this))
    end subroutine ascii_render_axes

subroutine ascii_clear_legend_lines(this, header)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in), optional :: header

        call ascii_clear_legend_impl(this%legend_lines, this%num_legend_lines, header)
        this%capturing_legend = .false.
    end subroutine ascii_clear_legend_lines

   
subroutine ascii_add_legend_entry(this, label, value_text)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: label
        character(len=*), intent(in), optional :: value_text

        call ascii_add_legend_entry_impl(label, value_text, this%legend_lines, this%num_legend_lines)
    end subroutine ascii_add_legend_entry

    subroutine ascii_clear_pie_legend_entries(this)
        class(ascii_context), intent(inout) :: this
        call ascii_clear_pie_legend_impl(this%legend_lines, this%num_legend_lines)
    end subroutine ascii_clear_pie_legend_entries

   subroutine ascii_register_pie_legend_entry(this, label, value_text)
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: label
        character(len=*), intent(in) :: value_text

        call ascii_register_pie_legend_impl(label, value_text, this%legend_lines, this%num_legend_lines)
    end subroutine ascii_register_pie_legend_entry

    subroutine ascii_set_text_charset(this, charset)
        !! Select the text charset. 'braille' ensures a subpixel canvas sized to
        !! the character grid; 'unicode'/'auto'/'ascii' are stored for the
        !! render-time frame/marker translation (#2060). An already-allocated
        !! braille canvas of the right size is preserved so re-saving an
        !! already-rendered figure keeps its dots.
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: charset
        logical :: needs_alloc

        select case (trim(adjustl(charset)))
        case ('braille')
            this%text_charset = 'braille'
            needs_alloc = .true.
            if (allocated(this%braille)) then
                needs_alloc = this%braille%n_cols /= this%plot_width .or. &
                              this%braille%n_rows /= this%plot_height
            end if
            if (needs_alloc) then
                if (allocated(this%braille)) deallocate (this%braille)
                allocate (this%braille)
                this%braille = create_braille_canvas(this%plot_width, this%plot_height)
            end if
        case ('unicode', 'auto')
            this%text_charset = trim(adjustl(charset))
            if (allocated(this%braille)) deallocate (this%braille)
        case default
            this%text_charset = 'ascii'
            if (allocated(this%braille)) deallocate (this%braille)
        end select
    end subroutine ascii_set_text_charset

    subroutine ascii_set_text_color_mode(this, mode)
        !! Store the ANSI color mode applied at output assembly. Unknown names
        !! normalize to 'never' so stray input never leaks escapes (#2062).
        class(ascii_context), intent(inout) :: this
        character(len=*), intent(in) :: mode

        this%text_color_mode = normalize_text_color_mode(mode)
    end subroutine ascii_set_text_color_mode

    logical function ascii_braille_active(this) result(active)
        !! Braille rendering is active only when the charset is 'braille' and a
        !! subpixel canvas has been allocated for it.
        class(ascii_context), intent(in) :: this

        active = .false.
        if (trim(this%text_charset) /= 'braille') return
        if (.not. allocated(this%braille)) return
        active = .true.
    end function ascii_braille_active

    subroutine ascii_data_to_subpixel(this, x, y, x_sub, y_sub, ok)
        !! Map a data-space point to an integer subpixel coordinate inside the
        !! braille canvas interior (matching the ASCII plot-area interior).
        class(ascii_context), intent(in) :: this
        real(wp), intent(in) :: x, y
        integer, intent(out) :: x_sub, y_sub
        logical, intent(out) :: ok

        real(wp) :: fx, fy
        integer :: col_lo, col_hi, row_lo, row_hi
        integer :: xs_lo, xs_hi, ys_lo, ys_hi

        ok = .false.
        x_sub = 0
        y_sub = 0
        if (this%x_max <= this%x_min) return
        if (this%y_max <= this%y_min) return

        fx = (x - this%x_min) / (this%x_max - this%x_min)
        fy = (y - this%y_min) / (this%y_max - this%y_min)
        fx = max(0.0_wp, min(1.0_wp, fx))
        fy = max(0.0_wp, min(1.0_wp, fy))

        if (this%plot_area%width > 0 .and. this%plot_area%height > 0) then
            col_lo = this%plot_area%left + 1
            col_hi = this%plot_area%left + this%plot_area%width - 1
            row_lo = this%plot_area%bottom + 1
            row_hi = this%plot_area%bottom + this%plot_area%height - 1
        else
            col_lo = 2
            col_hi = this%plot_width - 1
            row_lo = 2
            row_hi = this%plot_height - 1
        end if
        if (col_hi < col_lo) col_hi = col_lo
        if (row_hi < row_lo) row_hi = row_lo

        xs_lo = (col_lo - 1) * 2
        xs_hi = (col_hi - 1) * 2 + 1
        ys_lo = (row_lo - 1) * 4
        ys_hi = (row_hi - 1) * 4 + 3

        x_sub = xs_lo + nint(fx * real(xs_hi - xs_lo, wp))
        y_sub = ys_lo + nint((1.0_wp - fy) * real(ys_hi - ys_lo, wp))
        ok = .true.
    end subroutine ascii_data_to_subpixel

end module fortplot_ascii