Skip to main content

unicode_plot/
lineplot.rs

1use thiserror::Error;
2
3use crate::DecorationPosition;
4use crate::border::BorderType;
5use crate::canvas::{
6    AsciiCanvas, BlockCanvas, BrailleCanvas, Canvas, CanvasType, DensityCanvas, DotCanvas,
7};
8use crate::color::{CanvasColor, NamedColor, TermColor, canvas_color_from_term};
9use crate::math::{extend_limits, format_axis_value, usize_to_f64};
10use crate::plot::Plot;
11
12const MIN_WIDTH: usize = 5;
13const MIN_HEIGHT: usize = 2;
14const DEFAULT_HEIGHT: usize = 15;
15
16/// A runtime-selected canvas backend for line, scatter, and density plots.
17///
18/// Wraps all five canvas types behind a single enum so that plots can select
19/// the canvas at runtime via [`CanvasType`].
20#[derive(Debug, Clone, PartialEq)]
21#[non_exhaustive]
22pub enum GridCanvas {
23    Ascii(AsciiCanvas),
24    Block(BlockCanvas),
25    Braille(BrailleCanvas),
26    Density(DensityCanvas),
27    Dot(DotCanvas),
28}
29
30/// Step direction for staircase plots.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[non_exhaustive]
33pub enum StairStyle {
34    /// Step transition occurs at the start of each interval.
35    Pre,
36    /// Step transition occurs at the end of each interval (default).
37    #[default]
38    Post,
39}
40
41impl Canvas for GridCanvas {
42    fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
43        match self {
44            Self::Ascii(canvas) => canvas.pixel(x, y, color),
45            Self::Block(canvas) => canvas.pixel(x, y, color),
46            Self::Braille(canvas) => canvas.pixel(x, y, color),
47            Self::Density(canvas) => canvas.pixel(x, y, color),
48            Self::Dot(canvas) => canvas.pixel(x, y, color),
49        }
50    }
51
52    fn glyph_at(&self, col: usize, row: usize) -> char {
53        match self {
54            Self::Ascii(canvas) => canvas.glyph_at(col, row),
55            Self::Block(canvas) => canvas.glyph_at(col, row),
56            Self::Braille(canvas) => canvas.glyph_at(col, row),
57            Self::Density(canvas) => canvas.glyph_at(col, row),
58            Self::Dot(canvas) => canvas.glyph_at(col, row),
59        }
60    }
61
62    fn color_at(&self, col: usize, row: usize) -> CanvasColor {
63        match self {
64            Self::Ascii(canvas) => canvas.color_at(col, row),
65            Self::Block(canvas) => canvas.color_at(col, row),
66            Self::Braille(canvas) => canvas.color_at(col, row),
67            Self::Density(canvas) => canvas.color_at(col, row),
68            Self::Dot(canvas) => canvas.color_at(col, row),
69        }
70    }
71
72    fn char_width(&self) -> usize {
73        match self {
74            Self::Ascii(canvas) => canvas.char_width(),
75            Self::Block(canvas) => canvas.char_width(),
76            Self::Braille(canvas) => canvas.char_width(),
77            Self::Density(canvas) => canvas.char_width(),
78            Self::Dot(canvas) => canvas.char_width(),
79        }
80    }
81
82    fn char_height(&self) -> usize {
83        match self {
84            Self::Ascii(canvas) => canvas.char_height(),
85            Self::Block(canvas) => canvas.char_height(),
86            Self::Braille(canvas) => canvas.char_height(),
87            Self::Density(canvas) => canvas.char_height(),
88            Self::Dot(canvas) => canvas.char_height(),
89        }
90    }
91
92    fn pixel_width(&self) -> usize {
93        match self {
94            Self::Ascii(canvas) => canvas.pixel_width(),
95            Self::Block(canvas) => canvas.pixel_width(),
96            Self::Braille(canvas) => canvas.pixel_width(),
97            Self::Density(canvas) => canvas.pixel_width(),
98            Self::Dot(canvas) => canvas.pixel_width(),
99        }
100    }
101
102    fn pixel_height(&self) -> usize {
103        match self {
104            Self::Ascii(canvas) => canvas.pixel_height(),
105            Self::Block(canvas) => canvas.pixel_height(),
106            Self::Braille(canvas) => canvas.pixel_height(),
107            Self::Density(canvas) => canvas.pixel_height(),
108            Self::Dot(canvas) => canvas.pixel_height(),
109        }
110    }
111
112    fn transform(&self) -> &crate::canvas::Transform2D {
113        match self {
114            Self::Ascii(canvas) => canvas.transform(),
115            Self::Block(canvas) => canvas.transform(),
116            Self::Braille(canvas) => canvas.transform(),
117            Self::Density(canvas) => canvas.transform(),
118            Self::Dot(canvas) => canvas.transform(),
119        }
120    }
121
122    fn transform_mut(&mut self) -> &mut crate::canvas::Transform2D {
123        match self {
124            Self::Ascii(canvas) => canvas.transform_mut(),
125            Self::Block(canvas) => canvas.transform_mut(),
126            Self::Braille(canvas) => canvas.transform_mut(),
127            Self::Density(canvas) => canvas.transform_mut(),
128            Self::Dot(canvas) => canvas.transform_mut(),
129        }
130    }
131}
132
133/// Errors returned by lineplot, scatterplot, stairs, and density construction.
134#[derive(Debug, Error, PartialEq)]
135#[non_exhaustive]
136pub enum LineplotError {
137    /// The x and y arrays have different lengths.
138    #[error("x and y must be the same length")]
139    LengthMismatch,
140    /// Input arrays are empty.
141    #[error("x and y must not be empty")]
142    EmptySeries,
143    /// Explicit axis limits contain non-finite values.
144    #[error("axis limits must contain finite values")]
145    InvalidAxisLimits,
146    /// A data value could not be parsed as a finite number.
147    #[error("invalid numeric value: {value}")]
148    InvalidNumericValue { value: String },
149    /// `densityplot_add` was called on a non-density plot.
150    #[error("densityplot_add requires a density plot")]
151    DensityPlotRequired,
152}
153
154/// Options for an additional series appended to an existing lineplot or scatter plot.
155#[derive(Debug, Clone, Default)]
156#[non_exhaustive]
157pub struct LineplotSeriesOptions {
158    /// Series color. `None` uses the auto-cycling palette.
159    pub color: Option<TermColor>,
160    /// Series name shown in the legend annotation.
161    pub name: Option<String>,
162}
163
164/// Configuration for lineplot, scatterplot, stairs, and density construction.
165#[derive(Debug, Clone)]
166#[non_exhaustive]
167pub struct LineplotOptions {
168    /// Plot title displayed above the chart.
169    pub title: Option<String>,
170    /// Label below the x-axis.
171    pub xlabel: Option<String>,
172    /// Label to the left of the y-axis.
173    pub ylabel: Option<String>,
174    /// Border style (default: [`BorderType::Solid`]).
175    pub border: BorderType,
176    /// Left margin in characters (default: 3).
177    pub margin: u16,
178    /// Padding between border and content (default: 1).
179    pub padding: u16,
180    /// Whether to show axis labels (default: true).
181    pub labels: bool,
182    /// Canvas width in characters (default: 40, minimum: 5).
183    pub width: usize,
184    /// Canvas height in characters (default: 15, minimum: 2).
185    pub height: usize,
186    /// Explicit x-axis limits. `(0.0, 0.0)` means auto-detect from data.
187    pub xlim: (f64, f64),
188    /// Explicit y-axis limits. `(0.0, 0.0)` means auto-detect from data.
189    pub ylim: (f64, f64),
190    /// Canvas rendering backend (default: [`CanvasType::Braille`]).
191    pub canvas: CanvasType,
192    /// Whether to draw grid lines on the zero axes (default: true).
193    pub grid: bool,
194    /// Series color. `None` uses the auto-cycling palette.
195    pub color: Option<TermColor>,
196    /// Series name shown in the legend annotation.
197    pub name: Option<String>,
198}
199
200impl Default for LineplotOptions {
201    fn default() -> Self {
202        Self {
203            title: None,
204            xlabel: None,
205            ylabel: None,
206            border: BorderType::Solid,
207            margin: Plot::<GridCanvas>::DEFAULT_MARGIN,
208            padding: Plot::<GridCanvas>::DEFAULT_PADDING,
209            labels: true,
210            width: 40,
211            height: DEFAULT_HEIGHT,
212            xlim: (0.0, 0.0),
213            ylim: (0.0, 0.0),
214            canvas: CanvasType::Braille,
215            grid: true,
216            color: None,
217            name: None,
218        }
219    }
220}
221
222/// Constructs a lineplot from explicit x/y values.
223///
224/// # Examples
225///
226/// ```
227/// use unicode_plot::{lineplot, LineplotOptions};
228///
229/// let x: Vec<f64> = (0..20).map(|i| f64::from(i) * 0.1).collect();
230/// let y: Vec<f64> = x.iter().map(|v| v.sin()).collect();
231/// let plot = lineplot(&x, &y, LineplotOptions::default()).unwrap();
232///
233/// let mut buf = Vec::new();
234/// plot.render(&mut buf, false).unwrap();
235/// assert!(!buf.is_empty());
236/// ```
237///
238/// # Errors
239///
240/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
241/// [`LineplotError::EmptySeries`] for empty input, and
242/// [`LineplotError::InvalidNumericValue`] when parsing fails.
243pub fn lineplot<X: ToString, Y: ToString>(
244    x: &[X],
245    y: &[Y],
246    options: LineplotOptions,
247) -> Result<Plot<GridCanvas>, LineplotError> {
248    let x = parse_numbers(x)?;
249    let y = parse_numbers(y)?;
250    build_lineplot(&x, &y, options)
251}
252
253/// Constructs a lineplot from y values with implicit x = 1..=n.
254///
255/// # Errors
256///
257/// Returns [`LineplotError::EmptySeries`] for empty input and
258/// [`LineplotError::InvalidNumericValue`] when parsing fails.
259pub fn lineplot_y<Y: ToString>(
260    y: &[Y],
261    options: LineplotOptions,
262) -> Result<Plot<GridCanvas>, LineplotError> {
263    let y = parse_numbers(y)?;
264    if y.is_empty() {
265        return Err(LineplotError::EmptySeries);
266    }
267    let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
268    build_lineplot(&x, &y, options)
269}
270
271/// Constructs a scatterplot from explicit x/y values.
272///
273/// # Examples
274///
275/// ```
276/// use unicode_plot::{scatterplot, LineplotOptions};
277///
278/// let x = vec![1.0, 2.5, 3.0, 4.5, 5.0];
279/// let y = vec![2.0, 1.5, 4.0, 3.5, 5.0];
280/// let plot = scatterplot(&x, &y, LineplotOptions::default()).unwrap();
281///
282/// let mut buf = Vec::new();
283/// plot.render(&mut buf, false).unwrap();
284/// assert!(!buf.is_empty());
285/// ```
286///
287/// # Errors
288///
289/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
290/// [`LineplotError::EmptySeries`] for empty input, and
291/// [`LineplotError::InvalidNumericValue`] when parsing fails.
292pub fn scatterplot<X: ToString, Y: ToString>(
293    x: &[X],
294    y: &[Y],
295    options: LineplotOptions,
296) -> Result<Plot<GridCanvas>, LineplotError> {
297    let x = parse_numbers(x)?;
298    let y = parse_numbers(y)?;
299    build_scatterplot(&x, &y, options)
300}
301
302/// Constructs a scatterplot from y values with implicit x = 1..=n.
303///
304/// # Errors
305///
306/// Returns [`LineplotError::EmptySeries`] for empty input and
307/// [`LineplotError::InvalidNumericValue`] when parsing fails.
308pub fn scatterplot_y<Y: ToString>(
309    y: &[Y],
310    options: LineplotOptions,
311) -> Result<Plot<GridCanvas>, LineplotError> {
312    let y = parse_numbers(y)?;
313    if y.is_empty() {
314        return Err(LineplotError::EmptySeries);
315    }
316    let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
317    build_scatterplot(&x, &y, options)
318}
319
320/// Constructs a densityplot from explicit x/y values.
321///
322/// Density plots always use [`CanvasType::Density`] and disable grid rendering,
323/// regardless of the provided [`LineplotOptions`].
324///
325/// # Examples
326///
327/// ```
328/// use unicode_plot::{LineplotOptions, densityplot};
329///
330/// let mut options = LineplotOptions::default();
331/// options.title = Some("Example Density".to_owned());
332///
333/// let x = vec![1.0, 1.0, 2.0, 2.0, 3.0, 3.0];
334/// let y = vec![2.0, 2.0, 3.0, 3.0, 4.0, 4.0];
335/// let plot = densityplot(&x, &y, options).unwrap();
336///
337/// let mut buf = Vec::new();
338/// plot.render(&mut buf, false).unwrap();
339/// let rendered = String::from_utf8(buf).unwrap();
340/// assert!(rendered.contains("Example Density"));
341/// ```
342///
343/// # Errors
344///
345/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
346/// [`LineplotError::EmptySeries`] for empty input, and
347/// [`LineplotError::InvalidNumericValue`] when parsing fails.
348pub fn densityplot<X: ToString, Y: ToString>(
349    x: &[X],
350    y: &[Y],
351    mut options: LineplotOptions,
352) -> Result<Plot<GridCanvas>, LineplotError> {
353    let x = parse_numbers(x)?;
354    let y = parse_numbers(y)?;
355    options.canvas = CanvasType::Density;
356    options.grid = false;
357    build_scatterplot(&x, &y, options)
358}
359
360/// Constructs a staircase plot from explicit x/y values.
361///
362/// # Examples
363///
364/// ```
365/// use unicode_plot::{LineplotOptions, StairStyle, stairs};
366///
367/// let mut options = LineplotOptions::default();
368/// options.title = Some("Example Stairs".to_owned());
369///
370/// let x = vec![1.0, 2.0, 3.0, 4.0];
371/// let y = vec![2.0, 1.0, 3.0, 2.0];
372/// let plot = stairs(&x, &y, StairStyle::Post, options).unwrap();
373///
374/// let mut buf = Vec::new();
375/// plot.render(&mut buf, false).unwrap();
376/// let rendered = String::from_utf8(buf).unwrap();
377/// assert!(rendered.contains("Example Stairs"));
378/// ```
379///
380/// # Errors
381///
382/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
383/// [`LineplotError::EmptySeries`] for empty input, and
384/// [`LineplotError::InvalidNumericValue`] when parsing fails.
385pub fn stairs<X: ToString, Y: ToString>(
386    x: &[X],
387    y: &[Y],
388    style: StairStyle,
389    options: LineplotOptions,
390) -> Result<Plot<GridCanvas>, LineplotError> {
391    let x = parse_numbers(x)?;
392    let y = parse_numbers(y)?;
393    validate_series(&x, &y)?;
394    let (stair_x, stair_y) = compute_stair_lines(&x, &y, style);
395    build_lineplot(&stair_x, &stair_y, options)
396}
397
398/// Adds a line series to an existing plot.
399///
400/// When `x` and `y` each contain exactly one value, the pair is interpreted as
401/// `(intercept, slope)` and the series is rendered across the plot's current
402/// x-axis bounds.
403///
404/// # Errors
405///
406/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
407/// [`LineplotError::EmptySeries`] for empty input, and
408/// [`LineplotError::InvalidNumericValue`] when parsing fails.
409pub fn lineplot_add<X: ToString, Y: ToString>(
410    plot: &mut Plot<GridCanvas>,
411    x: &[X],
412    y: &[Y],
413    options: LineplotSeriesOptions,
414) -> Result<(), LineplotError> {
415    let x = parse_numbers(x)?;
416    let y = parse_numbers(y)?;
417    add_series(plot, &x, &y, options)
418}
419
420/// Adds a scatter series to an existing plot.
421///
422/// # Errors
423///
424/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
425/// [`LineplotError::EmptySeries`] for empty input, and
426/// [`LineplotError::InvalidNumericValue`] when parsing fails.
427pub fn scatterplot_add<X: ToString, Y: ToString>(
428    plot: &mut Plot<GridCanvas>,
429    x: &[X],
430    y: &[Y],
431    options: LineplotSeriesOptions,
432) -> Result<(), LineplotError> {
433    let x = parse_numbers(x)?;
434    let y = parse_numbers(y)?;
435    add_scatter_series(plot, &x, &y, options)
436}
437
438/// Adds a density series to an existing density plot.
439///
440/// # Errors
441///
442/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
443/// [`LineplotError::EmptySeries`] for empty input, and
444/// [`LineplotError::InvalidNumericValue`] when parsing fails. Returns
445/// [`LineplotError::DensityPlotRequired`] when called on a non-density plot.
446pub fn densityplot_add<X: ToString, Y: ToString>(
447    plot: &mut Plot<GridCanvas>,
448    x: &[X],
449    y: &[Y],
450    options: LineplotSeriesOptions,
451) -> Result<(), LineplotError> {
452    let x = parse_numbers(x)?;
453    let y = parse_numbers(y)?;
454    add_density_series(plot, &x, &y, options)
455}
456
457/// Adds a slope/intercept line to an existing plot.
458///
459/// The line is defined as `y = intercept + slope * x` across the plot's
460/// current x-axis limits.
461///
462/// # Errors
463///
464/// Returns [`LineplotError::InvalidNumericValue`] for non-finite values.
465pub fn lineplot_add_slope(
466    plot: &mut Plot<GridCanvas>,
467    intercept: f64,
468    slope: f64,
469    options: LineplotSeriesOptions,
470) -> Result<(), LineplotError> {
471    if !intercept.is_finite() {
472        return Err(LineplotError::InvalidNumericValue {
473            value: intercept.to_string(),
474        });
475    }
476    if !slope.is_finite() {
477        return Err(LineplotError::InvalidNumericValue {
478            value: slope.to_string(),
479        });
480    }
481
482    add_series(plot, &[intercept], &[slope], options)
483}
484
485/// Adds a staircase series to an existing plot.
486///
487/// # Errors
488///
489/// Returns [`LineplotError::LengthMismatch`] when lengths differ,
490/// [`LineplotError::EmptySeries`] for empty input, and
491/// [`LineplotError::InvalidNumericValue`] when parsing fails.
492pub fn stairs_add<X: ToString, Y: ToString>(
493    plot: &mut Plot<GridCanvas>,
494    x: &[X],
495    y: &[Y],
496    style: StairStyle,
497    options: LineplotSeriesOptions,
498) -> Result<(), LineplotError> {
499    let x = parse_numbers(x)?;
500    let y = parse_numbers(y)?;
501    validate_series(&x, &y)?;
502    let (stair_x, stair_y) = compute_stair_lines(&x, &y, style);
503    add_series(plot, &stair_x, &stair_y, options)
504}
505
506/// Adds a y-only line series to an existing plot with implicit x = 1..=n.
507///
508/// # Errors
509///
510/// Returns [`LineplotError::EmptySeries`] for empty input and
511/// [`LineplotError::InvalidNumericValue`] when parsing fails.
512pub fn lineplot_add_y<Y: ToString>(
513    plot: &mut Plot<GridCanvas>,
514    y: &[Y],
515    options: LineplotSeriesOptions,
516) -> Result<(), LineplotError> {
517    let y = parse_numbers(y)?;
518    if y.is_empty() {
519        return Err(LineplotError::EmptySeries);
520    }
521    let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
522    add_series(plot, &x, &y, options)
523}
524
525/// Adds a y-only scatter series to an existing plot with implicit x = 1..=n.
526///
527/// # Errors
528///
529/// Returns [`LineplotError::EmptySeries`] for empty input and
530/// [`LineplotError::InvalidNumericValue`] when parsing fails.
531pub fn scatterplot_add_y<Y: ToString>(
532    plot: &mut Plot<GridCanvas>,
533    y: &[Y],
534    options: LineplotSeriesOptions,
535) -> Result<(), LineplotError> {
536    let y = parse_numbers(y)?;
537    if y.is_empty() {
538        return Err(LineplotError::EmptySeries);
539    }
540    let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
541    add_scatter_series(plot, &x, &y, options)
542}
543
544/// Places edge annotation text on a line plot.
545pub fn annotate(
546    plot: &mut Plot<GridCanvas>,
547    position: DecorationPosition,
548    text: impl Into<String>,
549) {
550    plot.set_decoration(position, text);
551}
552
553fn compute_stair_lines(x: &[f64], y: &[f64], style: StairStyle) -> (Vec<f64>, Vec<f64>) {
554    let mut x_out = Vec::with_capacity(x.len().saturating_mul(2).saturating_sub(1));
555    let mut y_out = Vec::with_capacity(y.len().saturating_mul(2).saturating_sub(1));
556
557    x_out.push(x[0]);
558    y_out.push(y[0]);
559
560    for i in 1..x.len() {
561        match style {
562            StairStyle::Post => {
563                x_out.push(x[i]);
564                y_out.push(y[i - 1]);
565                x_out.push(x[i]);
566                y_out.push(y[i]);
567            }
568            StairStyle::Pre => {
569                x_out.push(x[i - 1]);
570                y_out.push(y[i]);
571                x_out.push(x[i]);
572                y_out.push(y[i]);
573            }
574        }
575    }
576
577    (x_out, y_out)
578}
579
580fn build_lineplot(
581    x: &[f64],
582    y: &[f64],
583    options: LineplotOptions,
584) -> Result<Plot<GridCanvas>, LineplotError> {
585    validate_series(x, y)?;
586    validate_limits(options.xlim, options.ylim)?;
587
588    let width = options.width.max(MIN_WIDTH);
589    let height = options.height.max(MIN_HEIGHT);
590    let (min_x, max_x) = extend_limits(x, options.xlim);
591    let (min_y, max_y) = extend_limits(y, options.ylim);
592
593    let canvas = create_canvas(
594        options.canvas,
595        width,
596        height,
597        min_x,
598        min_y,
599        max_x - min_x,
600        max_y - min_y,
601    );
602
603    let mut plot = Plot::new(canvas);
604    plot.title = options.title;
605    plot.xlabel = options.xlabel;
606    plot.ylabel = options.ylabel;
607    plot.border = options.border;
608    plot.margin = options.margin;
609    plot.padding = options.padding;
610    plot.show_labels = options.labels;
611
612    annotate_axes(&mut plot, min_x, max_x, min_y, max_y);
613
614    if options.grid {
615        draw_grid_lines(plot.graphics_mut(), min_x, max_x, min_y, max_y);
616    }
617
618    let series_options = LineplotSeriesOptions {
619        color: options.color,
620        name: options.name,
621    };
622    add_series(&mut plot, x, y, series_options)?;
623
624    Ok(plot)
625}
626
627fn build_scatterplot(
628    x: &[f64],
629    y: &[f64],
630    options: LineplotOptions,
631) -> Result<Plot<GridCanvas>, LineplotError> {
632    validate_series(x, y)?;
633    validate_limits(options.xlim, options.ylim)?;
634
635    let width = options.width.max(MIN_WIDTH);
636    let height = options.height.max(MIN_HEIGHT);
637    let (min_x, max_x) = extend_limits(x, options.xlim);
638    let (min_y, max_y) = extend_limits(y, options.ylim);
639
640    let canvas = create_canvas(
641        options.canvas,
642        width,
643        height,
644        min_x,
645        min_y,
646        max_x - min_x,
647        max_y - min_y,
648    );
649
650    let mut plot = Plot::new(canvas);
651    plot.title = options.title;
652    plot.xlabel = options.xlabel;
653    plot.ylabel = options.ylabel;
654    plot.border = options.border;
655    plot.margin = options.margin;
656    plot.padding = options.padding;
657    plot.show_labels = options.labels;
658
659    annotate_axes(&mut plot, min_x, max_x, min_y, max_y);
660
661    if options.grid {
662        draw_grid_lines(plot.graphics_mut(), min_x, max_x, min_y, max_y);
663    }
664
665    let series_options = LineplotSeriesOptions {
666        color: options.color,
667        name: options.name,
668    };
669    add_scatter_series(&mut plot, x, y, series_options)?;
670
671    Ok(plot)
672}
673
674fn add_series(
675    plot: &mut Plot<GridCanvas>,
676    x: &[f64],
677    y: &[f64],
678    options: LineplotSeriesOptions,
679) -> Result<(), LineplotError> {
680    validate_series(x, y)?;
681    let color = options
682        .color
683        .unwrap_or_else(|| TermColor::Named(plot.next_color()));
684
685    let canvas_color = canvas_color_from_term(color);
686    if x.len() == 1 {
687        let (slope_x, slope_y) = slope_segment_for_plot(plot, x[0], y[0]);
688        plot.graphics_mut().lines(&slope_x, &slope_y, canvas_color);
689    } else {
690        plot.graphics_mut().lines(x, y, canvas_color);
691    }
692
693    if let Some(name) = options.name.filter(|value| !value.is_empty()) {
694        annotate_next_right(plot, name, color);
695    }
696
697    Ok(())
698}
699
700fn add_scatter_series(
701    plot: &mut Plot<GridCanvas>,
702    x: &[f64],
703    y: &[f64],
704    options: LineplotSeriesOptions,
705) -> Result<(), LineplotError> {
706    validate_series(x, y)?;
707    let color = options
708        .color
709        .unwrap_or_else(|| TermColor::Named(plot.next_color()));
710
711    let canvas_color = canvas_color_from_term(color);
712    plot.graphics_mut().points(x, y, canvas_color);
713
714    if let Some(name) = options.name.filter(|value| !value.is_empty()) {
715        annotate_next_right(plot, name, color);
716    }
717
718    Ok(())
719}
720
721fn add_density_series(
722    plot: &mut Plot<GridCanvas>,
723    x: &[f64],
724    y: &[f64],
725    options: LineplotSeriesOptions,
726) -> Result<(), LineplotError> {
727    if !matches!(plot.graphics(), GridCanvas::Density(_)) {
728        return Err(LineplotError::DensityPlotRequired);
729    }
730    add_scatter_series(plot, x, y, options)
731}
732
733fn slope_segment_for_plot(
734    plot: &Plot<GridCanvas>,
735    intercept: f64,
736    slope: f64,
737) -> ([f64; 2], [f64; 2]) {
738    let x_axis = plot.graphics().transform().x();
739    let min_x = x_axis.origin();
740    let max_x = x_axis.origin() + x_axis.span();
741    let slope_x = [min_x, max_x];
742    let slope_y = [intercept + min_x * slope, intercept + max_x * slope];
743    (slope_x, slope_y)
744}
745
746fn annotate_next_right(plot: &mut Plot<GridCanvas>, text: String, color: TermColor) {
747    for row in 0..plot.graphics().char_height() {
748        if !plot.annotations().right().contains_key(&row) {
749            plot.annotate_right(row, text, Some(color));
750            return;
751        }
752    }
753}
754
755fn draw_grid_lines(canvas: &mut GridCanvas, min_x: f64, max_x: f64, min_y: f64, max_y: f64) {
756    if min_y < 0.0 && 0.0 < max_y {
757        let x_steps = canvas.pixel_width().saturating_sub(1);
758        if x_steps > 0 {
759            let step = (max_x - min_x) / usize_to_f64(x_steps);
760            if step.is_finite() && step > 0.0 {
761                let mut value = min_x;
762                while value <= max_x {
763                    canvas.point(value, 0.0, CanvasColor::NORMAL);
764                    value += step;
765                }
766                canvas.point(max_x, 0.0, CanvasColor::NORMAL);
767            }
768        }
769    }
770
771    if min_x < 0.0 && 0.0 < max_x {
772        let y_steps = canvas.pixel_height().saturating_sub(1);
773        if y_steps > 0 {
774            let step = (max_y - min_y) / usize_to_f64(y_steps);
775            if step.is_finite() && step > 0.0 {
776                let mut value = min_y;
777                while value <= max_y {
778                    canvas.point(0.0, value, CanvasColor::NORMAL);
779                    value += step;
780                }
781                canvas.point(0.0, max_y, CanvasColor::NORMAL);
782            }
783        }
784    }
785}
786
787fn annotate_axes(plot: &mut Plot<GridCanvas>, min_x: f64, max_x: f64, min_y: f64, max_y: f64) {
788    let y_max = format_axis_value(max_y);
789    let y_min = format_axis_value(min_y);
790    plot.annotate_left(0, y_max, Some(TermColor::Named(NamedColor::LightBlack)));
791    plot.annotate_left(
792        plot.graphics().char_height().saturating_sub(1),
793        y_min,
794        Some(TermColor::Named(NamedColor::LightBlack)),
795    );
796
797    plot.set_decoration(crate::DecorationPosition::Bl, format_axis_value(min_x));
798    plot.set_decoration(crate::DecorationPosition::Br, format_axis_value(max_x));
799}
800
801fn validate_limits(xlim: (f64, f64), ylim: (f64, f64)) -> Result<(), LineplotError> {
802    if !xlim.0.is_finite() || !xlim.1.is_finite() || !ylim.0.is_finite() || !ylim.1.is_finite() {
803        return Err(LineplotError::InvalidAxisLimits);
804    }
805
806    Ok(())
807}
808
809fn validate_series(x: &[f64], y: &[f64]) -> Result<(), LineplotError> {
810    if x.len() != y.len() {
811        return Err(LineplotError::LengthMismatch);
812    }
813    if x.is_empty() {
814        return Err(LineplotError::EmptySeries);
815    }
816    Ok(())
817}
818
819fn parse_numbers<T: ToString>(values: &[T]) -> Result<Vec<f64>, LineplotError> {
820    values
821        .iter()
822        .map(|value| {
823            let display = value.to_string();
824            let numeric =
825                display
826                    .parse::<f64>()
827                    .map_err(|_| LineplotError::InvalidNumericValue {
828                        value: display.clone(),
829                    })?;
830            if !numeric.is_finite() {
831                return Err(LineplotError::InvalidNumericValue { value: display });
832            }
833            Ok(numeric)
834        })
835        .collect()
836}
837
838fn create_canvas(
839    canvas_type: CanvasType,
840    width: usize,
841    height: usize,
842    origin_x: f64,
843    origin_y: f64,
844    plot_width: f64,
845    plot_height: f64,
846) -> GridCanvas {
847    match canvas_type {
848        CanvasType::Ascii => GridCanvas::Ascii(AsciiCanvas::new(
849            width,
850            height,
851            origin_x,
852            origin_y,
853            plot_width,
854            plot_height,
855        )),
856        CanvasType::Block => GridCanvas::Block(BlockCanvas::new(
857            width,
858            height,
859            origin_x,
860            origin_y,
861            plot_width,
862            plot_height,
863        )),
864        CanvasType::Braille => GridCanvas::Braille(BrailleCanvas::new(
865            width,
866            height,
867            origin_x,
868            origin_y,
869            plot_width,
870            plot_height,
871        )),
872        CanvasType::Density => GridCanvas::Density(DensityCanvas::new(
873            width,
874            height,
875            origin_x,
876            origin_y,
877            plot_width,
878            plot_height,
879        )),
880        CanvasType::Dot => GridCanvas::Dot(DotCanvas::new(
881            width,
882            height,
883            origin_x,
884            origin_y,
885            plot_width,
886            plot_height,
887        )),
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use std::fs;
894    use std::path::Path;
895
896    use super::{
897        GridCanvas, LineplotError, LineplotOptions, LineplotSeriesOptions, StairStyle, annotate,
898        densityplot, densityplot_add, lineplot, lineplot_add, lineplot_add_slope, lineplot_add_y,
899        lineplot_y, scatterplot, scatterplot_add, scatterplot_add_y, scatterplot_y, stairs,
900        stairs_add,
901    };
902    use crate::DecorationPosition;
903    use crate::canvas::{Canvas, CanvasType};
904    use crate::color::{NamedColor, TermColor};
905    use crate::math::usize_to_f64;
906    use crate::parse_border_type;
907    use crate::test_util::{assert_fixture_eq, render_plot_text};
908
909    fn load_density_fixture_data() -> (Vec<f64>, Vec<f64>) {
910        let fixture_path =
911            Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/data/randn_1338_2000.txt");
912        let values: Vec<f64> = fs::read_to_string(&fixture_path)
913            .unwrap_or_else(|error| panic!("failed to read {}: {error}", fixture_path.display()))
914            .lines()
915            .map(str::trim)
916            .filter(|line| !line.is_empty())
917            .map(|line| {
918                line.parse::<f64>().unwrap_or_else(|error| {
919                    panic!("failed to parse density fixture value `{line}`: {error}")
920                })
921            })
922            .collect();
923        assert_eq!(
924            values.len(),
925            2000,
926            "density fixture should have 2000 values"
927        );
928        let dx = values[0..1000].to_vec();
929        let dy = values[1000..2000].to_vec();
930        (dx, dy)
931    }
932
933    fn assert_fixture_render(plot: &crate::Plot<GridCanvas>, color: bool, fixture: &str) {
934        assert_fixture_eq(&render_plot_text(plot, color), fixture);
935    }
936
937    fn base_x() -> Vec<i32> {
938        vec![-1, 1, 3, 3, -1]
939    }
940
941    fn base_y() -> Vec<i32> {
942        vec![2, 0, -5, 2, -5]
943    }
944
945    #[test]
946    fn validates_arguments_and_inputs() {
947        let x = [1, 2];
948        let y = [1, 2, 3];
949        let mismatch = lineplot(&x, &y, LineplotOptions::default());
950        assert!(matches!(mismatch, Err(LineplotError::LengthMismatch)));
951
952        let empty = lineplot(&[] as &[i32], &[] as &[i32], LineplotOptions::default());
953        assert!(matches!(empty, Err(LineplotError::EmptySeries)));
954
955        let invalid = lineplot(&["a"], &["1"], LineplotOptions::default());
956        assert!(matches!(
957            invalid,
958            Err(LineplotError::InvalidNumericValue { .. })
959        ));
960    }
961
962    #[test]
963    fn default_fixture() {
964        let plot = lineplot(&base_x(), &base_y(), LineplotOptions::default())
965            .expect("lineplot should succeed");
966        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/default.txt");
967    }
968
969    #[test]
970    fn shared_renderer_matches_lineplot_default_fixture() {
971        let plot = lineplot(&base_x(), &base_y(), LineplotOptions::default())
972            .expect("lineplot should succeed");
973
974        let ansi_rendered = render_plot_text(&plot, true);
975        assert_fixture_eq(&ansi_rendered, "tests/fixtures/lineplot/default.txt");
976
977        let plain_rendered = render_plot_text(&plot, false);
978        assert!(!plain_rendered.contains("\x1b["));
979    }
980
981    #[test]
982    fn y_only_and_range_fixtures() {
983        let plot =
984            lineplot_y(&base_y(), LineplotOptions::default()).expect("lineplot should succeed");
985        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/y_only.txt");
986
987        let range1: Vec<i32> = (6..=10).collect();
988        let plot =
989            lineplot_y(&range1, LineplotOptions::default()).expect("lineplot should succeed");
990        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/range1.txt");
991
992        let x: Vec<i32> = (11..=15).collect();
993        let y: Vec<i32> = (6..=10).collect();
994        let plot = lineplot(&x, &y, LineplotOptions::default()).expect("lineplot should succeed");
995        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/range2.txt");
996    }
997
998    #[test]
999    fn limits_nogrid_and_canvas_size_fixtures() {
1000        let plot = lineplot(
1001            &base_x(),
1002            &base_y(),
1003            LineplotOptions {
1004                xlim: (-1.5, 3.5),
1005                ylim: (-5.5, 2.5),
1006                ..LineplotOptions::default()
1007            },
1008        )
1009        .expect("lineplot should succeed");
1010        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/limits.txt");
1011
1012        let plot = lineplot(
1013            &base_x(),
1014            &base_y(),
1015            LineplotOptions {
1016                grid: false,
1017                ..LineplotOptions::default()
1018            },
1019        )
1020        .expect("lineplot should succeed");
1021        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/nogrid.txt");
1022
1023        let plot = lineplot(
1024            &base_x(),
1025            &base_y(),
1026            LineplotOptions {
1027                title: Some(String::from("Scatter")),
1028                canvas: CanvasType::Dot,
1029                width: 10,
1030                height: 5,
1031                ..LineplotOptions::default()
1032            },
1033        )
1034        .expect("lineplot should succeed");
1035        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/canvassize.txt");
1036    }
1037
1038    #[test]
1039    fn named_color_and_parameters_fixtures() {
1040        let mut plot = lineplot(
1041            &base_x(),
1042            &base_y(),
1043            LineplotOptions {
1044                color: Some(TermColor::Named(NamedColor::Blue)),
1045                name: Some(String::from("points1")),
1046                ..LineplotOptions::default()
1047            },
1048        )
1049        .expect("lineplot should succeed");
1050        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/blue.txt");
1051
1052        plot = lineplot(
1053            &base_x(),
1054            &base_y(),
1055            LineplotOptions {
1056                name: Some(String::from("points1")),
1057                title: Some(String::from("Scatter")),
1058                xlabel: Some(String::from("x")),
1059                ylabel: Some(String::from("y")),
1060                ..LineplotOptions::default()
1061            },
1062        )
1063        .expect("lineplot should succeed");
1064        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/parameters1.txt");
1065
1066        lineplot_add_y(
1067            &mut plot,
1068            &[0.5, 1.0, 1.5],
1069            LineplotSeriesOptions {
1070                name: Some(String::from("points2")),
1071                ..LineplotSeriesOptions::default()
1072            },
1073        )
1074        .expect("append should succeed");
1075        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/parameters2.txt");
1076
1077        lineplot_add(
1078            &mut plot,
1079            &[-0.5, 0.5, 1.5],
1080            &[0.5, 1.0, 1.5],
1081            LineplotSeriesOptions {
1082                name: Some(String::from("points3")),
1083                ..LineplotSeriesOptions::default()
1084            },
1085        )
1086        .expect("append should succeed");
1087        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/parameters3.txt");
1088        assert_fixture_render(&plot, false, "tests/fixtures/lineplot/nocolor.txt");
1089    }
1090
1091    #[test]
1092    fn scale_and_issue32_fixtures() {
1093        let x1: Vec<f64> = base_x()
1094            .into_iter()
1095            .map(|value| f64::from(value) * 1e3 + 15.0)
1096            .collect();
1097        let y1: Vec<f64> = base_y()
1098            .into_iter()
1099            .map(|value| f64::from(value) * 1e-3 - 15.0)
1100            .collect();
1101        let plot = lineplot(&x1, &y1, LineplotOptions::default()).expect("lineplot should succeed");
1102        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale1.txt");
1103
1104        let x2: Vec<f64> = base_x()
1105            .into_iter()
1106            .map(|value| f64::from(value) * 1e-3 + 15.0)
1107            .collect();
1108        let y2: Vec<f64> = base_y()
1109            .into_iter()
1110            .map(|value| f64::from(value) * 1e3 - 15.0)
1111            .collect();
1112        let plot = lineplot(&x2, &y2, LineplotOptions::default()).expect("lineplot should succeed");
1113        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale2.txt");
1114
1115        let tx = [-1.0, 2.0, 3.0, 700_000.0];
1116        let ty = [1.0, 2.0, 9.0, 4_000_000.0];
1117        let plot = lineplot(&tx, &ty, LineplotOptions::default()).expect("lineplot should succeed");
1118        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale3.txt");
1119
1120        let plot = lineplot(
1121            &tx,
1122            &ty,
1123            LineplotOptions {
1124                width: 5,
1125                height: 5,
1126                ..LineplotOptions::default()
1127            },
1128        )
1129        .expect("lineplot should succeed");
1130        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale3_small.txt");
1131
1132        let ys = [
1133            261.0, 272.0, 277.0, 283.0, 289.0, 294.0, 298.0, 305.0, 309.0, 314.0, 319.0, 320.0,
1134            322.0, 323.0, 324.0,
1135        ];
1136        let xs: Vec<f64> = (0..ys.len()).map(usize_to_f64).collect();
1137        let plot = lineplot(
1138            &xs,
1139            &ys,
1140            LineplotOptions {
1141                height: 26,
1142                ylim: (0.0, 700.0),
1143                ..LineplotOptions::default()
1144            },
1145        )
1146        .expect("lineplot should succeed");
1147        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/issue32_fix.txt");
1148    }
1149
1150    #[test]
1151    fn stairs_pre_and_post_fixtures() {
1152        let sx = [1, 2, 4, 7, 8];
1153        let sy = [1, 3, 4, 2, 7];
1154
1155        let plot = stairs(&sx, &sy, StairStyle::Pre, LineplotOptions::default())
1156            .expect("stairs should succeed");
1157        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_pre.txt");
1158
1159        let plot = stairs(&sx, &sy, StairStyle::Post, LineplotOptions::default())
1160            .expect("stairs should succeed");
1161        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_post.txt");
1162    }
1163
1164    #[test]
1165    fn stairs_parameter_and_nocolor_fixtures() {
1166        let sx = [1.0, 2.0, 4.0, 7.0, 8.0];
1167        let sy = [1.0, 3.0, 4.0, 2.0, 7.0];
1168
1169        let mut plot = stairs(
1170            &sx,
1171            &sy,
1172            StairStyle::Post,
1173            LineplotOptions {
1174                title: Some(String::from("Foo")),
1175                color: Some(TermColor::Named(NamedColor::Red)),
1176                xlabel: Some(String::from("x")),
1177                name: Some(String::from("1")),
1178                ..LineplotOptions::default()
1179            },
1180        )
1181        .expect("stairs should succeed");
1182        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_parameters.txt");
1183
1184        let sx2: Vec<f64> = sx.iter().map(|value| *value - 0.2).collect();
1185        let sy2: Vec<f64> = sy.iter().map(|value| *value + 1.5).collect();
1186        stairs_add(
1187            &mut plot,
1188            &sx2,
1189            &sy2,
1190            StairStyle::Post,
1191            LineplotSeriesOptions {
1192                name: Some(String::from("2")),
1193                ..LineplotSeriesOptions::default()
1194            },
1195        )
1196        .expect("stairs add should succeed");
1197        assert_fixture_render(
1198            &plot,
1199            true,
1200            "tests/fixtures/lineplot/stairs_parameters2.txt",
1201        );
1202        assert_fixture_render(
1203            &plot,
1204            false,
1205            "tests/fixtures/lineplot/stairs_parameters2_nocolor.txt",
1206        );
1207
1208        stairs_add(
1209            &mut plot,
1210            &sx,
1211            &sy,
1212            StairStyle::Pre,
1213            LineplotSeriesOptions {
1214                name: Some(String::from("3")),
1215                ..LineplotSeriesOptions::default()
1216            },
1217        )
1218        .expect("stairs add should succeed");
1219        let rendered = render_plot_text(&plot, false);
1220        assert!(rendered.contains("Foo"));
1221        assert!(rendered.contains('1'));
1222        assert!(rendered.contains('2'));
1223        assert!(rendered.contains('3'));
1224    }
1225
1226    #[test]
1227    fn stairs_edgecase_fixture() {
1228        let sx = [1, 2, 4, 7, 8];
1229        let sy = [1, 3, 4, 2, 7000];
1230        let plot = stairs(&sx, &sy, StairStyle::Post, LineplotOptions::default())
1231            .expect("stairs should succeed");
1232        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_edgecase.txt");
1233    }
1234
1235    #[test]
1236    fn slope_fixtures() {
1237        let mut plot =
1238            lineplot_y(&base_y(), LineplotOptions::default()).expect("lineplot should succeed");
1239
1240        lineplot_add_slope(&mut plot, -3.0, 1.0, LineplotSeriesOptions::default())
1241            .expect("lineplot add should succeed");
1242        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/slope1.txt");
1243
1244        lineplot_add(
1245            &mut plot,
1246            &[-4.0],
1247            &[0.5],
1248            LineplotSeriesOptions {
1249                color: Some(TermColor::Named(NamedColor::Cyan)),
1250                name: Some(String::from("foo")),
1251            },
1252        )
1253        .expect("lineplot add should succeed");
1254        assert_fixture_render(&plot, true, "tests/fixtures/lineplot/slope2.txt");
1255    }
1256
1257    #[test]
1258    fn slope_mode_uses_plot_x_axis_bounds() {
1259        let mut plot = lineplot(
1260            &[0.0, 1.0],
1261            &[0.0, 1.0],
1262            LineplotOptions {
1263                xlim: (-2.0, 3.0),
1264                ylim: (-2.0, 2.0),
1265                ..LineplotOptions::default()
1266            },
1267        )
1268        .expect("lineplot should succeed");
1269
1270        lineplot_add_slope(&mut plot, 1.0, 0.5, LineplotSeriesOptions::default())
1271            .expect("slope add should succeed");
1272
1273        let rendered = render_plot_text(&plot, false);
1274        let has_bounds_line = rendered
1275            .lines()
1276            .any(|line| line.contains("-2") && line.contains('3'));
1277        assert!(
1278            has_bounds_line,
1279            "expected axis bounds line in rendered plot"
1280        );
1281    }
1282
1283    #[test]
1284    fn squeeze_annotations_fixture() {
1285        let sx = [1, 2, 4, 7, 8];
1286        let sy = [1, 3, 4, 2, 7];
1287        let mut plot = stairs(
1288            &sx,
1289            &sy,
1290            StairStyle::Post,
1291            LineplotOptions {
1292                width: 10,
1293                padding: 3,
1294                ..LineplotOptions::default()
1295            },
1296        )
1297        .expect("stairs should succeed");
1298
1299        annotate(&mut plot, DecorationPosition::Tl, "Hello");
1300        annotate(&mut plot, DecorationPosition::T, "how are");
1301        annotate(&mut plot, DecorationPosition::Tr, "you?");
1302        annotate(&mut plot, DecorationPosition::Bl, "Hello");
1303        annotate(&mut plot, DecorationPosition::B, "how are");
1304        annotate(&mut plot, DecorationPosition::Br, "you?");
1305
1306        lineplot_add(&mut plot, &[1.0], &[0.5], LineplotSeriesOptions::default())
1307            .expect("lineplot add should succeed");
1308
1309        assert_fixture_render(
1310            &plot,
1311            true,
1312            "tests/fixtures/lineplot/squeeze_annotations.txt",
1313        );
1314    }
1315
1316    #[test]
1317    fn scatterplot_validates_arguments_and_inputs() {
1318        let x = [1, 2];
1319        let y = [1, 2, 3];
1320        let mismatch = scatterplot(&x, &y, LineplotOptions::default());
1321        assert!(matches!(mismatch, Err(LineplotError::LengthMismatch)));
1322
1323        let empty = scatterplot(&[] as &[i32], &[] as &[i32], LineplotOptions::default());
1324        assert!(matches!(empty, Err(LineplotError::EmptySeries)));
1325
1326        let invalid = scatterplot(&["a"], &["1"], LineplotOptions::default());
1327        assert!(matches!(
1328            invalid,
1329            Err(LineplotError::InvalidNumericValue { .. })
1330        ));
1331
1332        let invalid_limits = scatterplot(
1333            &[1],
1334            &[1],
1335            LineplotOptions {
1336                xlim: (f64::NAN, 1.0),
1337                ..LineplotOptions::default()
1338            },
1339        );
1340        assert!(matches!(
1341            invalid_limits,
1342            Err(LineplotError::InvalidAxisLimits)
1343        ));
1344
1345        let y_empty = scatterplot_y(&[] as &[i32], LineplotOptions::default());
1346        assert!(matches!(y_empty, Err(LineplotError::EmptySeries)));
1347
1348        let y_invalid = scatterplot_y(&["NaN"], LineplotOptions::default());
1349        assert!(matches!(
1350            y_invalid,
1351            Err(LineplotError::InvalidNumericValue { .. })
1352        ));
1353
1354        let mut base_plot = scatterplot(&[1.0, 2.0], &[1.0, 2.0], LineplotOptions::default())
1355            .expect("base scatterplot should succeed");
1356        let add_mismatch = scatterplot_add(
1357            &mut base_plot,
1358            &[1.0, 2.0],
1359            &[1.0],
1360            LineplotSeriesOptions::default(),
1361        );
1362        assert!(matches!(add_mismatch, Err(LineplotError::LengthMismatch)));
1363
1364        let add_y_empty = scatterplot_add_y(
1365            &mut base_plot,
1366            &[] as &[f64],
1367            LineplotSeriesOptions::default(),
1368        );
1369        assert!(matches!(add_y_empty, Err(LineplotError::EmptySeries)));
1370
1371        let add_invalid = scatterplot_add(
1372            &mut base_plot,
1373            &["bad"],
1374            &["1"],
1375            LineplotSeriesOptions::default(),
1376        );
1377        assert!(matches!(
1378            add_invalid,
1379            Err(LineplotError::InvalidNumericValue { .. })
1380        ));
1381    }
1382
1383    #[test]
1384    fn scatterplot_draws_points_without_connecting_segments() {
1385        let options = LineplotOptions {
1386            canvas: CanvasType::Dot,
1387            width: 10,
1388            height: 5,
1389            grid: false,
1390            labels: false,
1391            xlim: (0.0, 3.0),
1392            ylim: (0.0, 3.0),
1393            ..LineplotOptions::default()
1394        };
1395
1396        let line =
1397            lineplot(&[0.0, 3.0], &[0.0, 3.0], options.clone()).expect("lineplot should succeed");
1398        let scatter =
1399            scatterplot(&[0.0, 3.0], &[0.0, 3.0], options).expect("scatterplot should succeed");
1400
1401        let line_cells = (0..line.graphics().char_height())
1402            .flat_map(|row| line.graphics().row_cells(row))
1403            .filter(|(glyph, _)| *glyph != ' ')
1404            .count();
1405        let scatter_cells = (0..scatter.graphics().char_height())
1406            .flat_map(|row| scatter.graphics().row_cells(row))
1407            .filter(|(glyph, _)| *glyph != ' ')
1408            .count();
1409
1410        assert!(
1411            line_cells > scatter_cells,
1412            "lineplot should occupy more cells than scatterplot"
1413        );
1414    }
1415
1416    #[test]
1417    fn scatterplot_default_y_and_range_fixtures() {
1418        let plot = scatterplot(&base_x(), &base_y(), LineplotOptions::default())
1419            .expect("scatterplot should succeed");
1420        assert_fixture_eq(
1421            &render_plot_text(&plot, true),
1422            "tests/fixtures/scatterplot/default.txt",
1423        );
1424
1425        let plot = scatterplot_y(&base_y(), LineplotOptions::default())
1426            .expect("scatterplot should succeed");
1427        assert_fixture_eq(
1428            &render_plot_text(&plot, true),
1429            "tests/fixtures/scatterplot/y_only.txt",
1430        );
1431
1432        let range1: Vec<i32> = (6..=10).collect();
1433        let plot =
1434            scatterplot_y(&range1, LineplotOptions::default()).expect("scatterplot should succeed");
1435        assert_fixture_eq(
1436            &render_plot_text(&plot, true),
1437            "tests/fixtures/scatterplot/range1.txt",
1438        );
1439
1440        let x: Vec<i32> = (11..=15).collect();
1441        let y: Vec<i32> = (6..=10).collect();
1442        let plot =
1443            scatterplot(&x, &y, LineplotOptions::default()).expect("scatterplot should succeed");
1444        assert_fixture_eq(
1445            &render_plot_text(&plot, true),
1446            "tests/fixtures/scatterplot/range2.txt",
1447        );
1448    }
1449
1450    #[test]
1451    fn scatterplot_scale_limits_and_nogrid_fixtures() {
1452        let x1: Vec<f64> = base_x()
1453            .into_iter()
1454            .map(|value| f64::from(value) * 1e3 + 15.0)
1455            .collect();
1456        let y1: Vec<f64> = base_y()
1457            .into_iter()
1458            .map(|value| f64::from(value) * 1e-3 - 15.0)
1459            .collect();
1460        let plot =
1461            scatterplot(&x1, &y1, LineplotOptions::default()).expect("scatterplot should succeed");
1462        assert_fixture_eq(
1463            &render_plot_text(&plot, true),
1464            "tests/fixtures/scatterplot/scale1.txt",
1465        );
1466
1467        let x2: Vec<f64> = base_x()
1468            .into_iter()
1469            .map(|value| f64::from(value) * 1e-3 + 15.0)
1470            .collect();
1471        let y2: Vec<f64> = base_y()
1472            .into_iter()
1473            .map(|value| f64::from(value) * 1e3 - 15.0)
1474            .collect();
1475        let plot =
1476            scatterplot(&x2, &y2, LineplotOptions::default()).expect("scatterplot should succeed");
1477        assert_fixture_eq(
1478            &render_plot_text(&plot, true),
1479            "tests/fixtures/scatterplot/scale2.txt",
1480        );
1481
1482        let tx = [-1.0, 2.0, 3.0, 700_000.0];
1483        let ty = [1.0, 2.0, 9.0, 4_000_000.0];
1484        let plot =
1485            scatterplot(&tx, &ty, LineplotOptions::default()).expect("scatterplot should succeed");
1486        assert_fixture_eq(
1487            &render_plot_text(&plot, true),
1488            "tests/fixtures/scatterplot/scale3.txt",
1489        );
1490
1491        let plot = scatterplot(
1492            &base_x(),
1493            &base_y(),
1494            LineplotOptions {
1495                xlim: (-1.5, 3.5),
1496                ylim: (-5.5, 2.5),
1497                ..LineplotOptions::default()
1498            },
1499        )
1500        .expect("scatterplot should succeed");
1501        assert_fixture_eq(
1502            &render_plot_text(&plot, true),
1503            "tests/fixtures/scatterplot/limits.txt",
1504        );
1505
1506        let plot = scatterplot(
1507            &base_x(),
1508            &base_y(),
1509            LineplotOptions {
1510                grid: false,
1511                ..LineplotOptions::default()
1512            },
1513        )
1514        .expect("scatterplot should succeed");
1515        assert_fixture_eq(
1516            &render_plot_text(&plot, true),
1517            "tests/fixtures/scatterplot/nogrid.txt",
1518        );
1519    }
1520
1521    #[test]
1522    fn scatterplot_color_parameters_and_canvas_size_fixtures() {
1523        let plot = scatterplot(
1524            &base_x(),
1525            &base_y(),
1526            LineplotOptions {
1527                color: Some(TermColor::Named(NamedColor::Blue)),
1528                name: Some(String::from("points1")),
1529                ..LineplotOptions::default()
1530            },
1531        )
1532        .expect("scatterplot should succeed");
1533        assert_fixture_eq(
1534            &render_plot_text(&plot, true),
1535            "tests/fixtures/scatterplot/blue.txt",
1536        );
1537
1538        let mut plot = scatterplot(
1539            &base_x(),
1540            &base_y(),
1541            LineplotOptions {
1542                name: Some(String::from("points1")),
1543                title: Some(String::from("Scatter")),
1544                xlabel: Some(String::from("x")),
1545                ylabel: Some(String::from("y")),
1546                ..LineplotOptions::default()
1547            },
1548        )
1549        .expect("scatterplot should succeed");
1550        assert_fixture_eq(
1551            &render_plot_text(&plot, true),
1552            "tests/fixtures/scatterplot/parameters1.txt",
1553        );
1554
1555        scatterplot_add_y(
1556            &mut plot,
1557            &[2.0, 0.5, -1.0, 1.0],
1558            LineplotSeriesOptions {
1559                name: Some(String::from("points2")),
1560                ..LineplotSeriesOptions::default()
1561            },
1562        )
1563        .expect("scatterplot add should succeed");
1564        assert_fixture_eq(
1565            &render_plot_text(&plot, true),
1566            "tests/fixtures/scatterplot/parameters2.txt",
1567        );
1568
1569        scatterplot_add(
1570            &mut plot,
1571            &[-0.5, 1.0, 2.5],
1572            &[0.5, 1.0, 1.5],
1573            LineplotSeriesOptions {
1574                name: Some(String::from("points3")),
1575                ..LineplotSeriesOptions::default()
1576            },
1577        )
1578        .expect("scatterplot add should succeed");
1579        assert_fixture_eq(
1580            &render_plot_text(&plot, true),
1581            "tests/fixtures/scatterplot/parameters3.txt",
1582        );
1583        assert_fixture_eq(
1584            &render_plot_text(&plot, false),
1585            "tests/fixtures/scatterplot/nocolor.txt",
1586        );
1587
1588        let plot = scatterplot(
1589            &base_x(),
1590            &base_y(),
1591            LineplotOptions {
1592                title: Some(String::from("Scatter")),
1593                canvas: CanvasType::Dot,
1594                width: 10,
1595                height: 5,
1596                ..LineplotOptions::default()
1597            },
1598        )
1599        .expect("scatterplot should succeed");
1600        assert_fixture_eq(
1601            &render_plot_text(&plot, true),
1602            "tests/fixtures/scatterplot/canvassize.txt",
1603        );
1604    }
1605
1606    #[test]
1607    fn densityplot_unknown_border_name_errors() {
1608        let err =
1609            parse_border_type("invalid_border_name").expect_err("unknown border name should fail");
1610        assert_eq!(
1611            err,
1612            crate::BarplotError::UnknownBorderType {
1613                name: String::from("invalid_border_name")
1614            }
1615        );
1616    }
1617
1618    #[test]
1619    fn densityplot_validates_inputs_and_add_requires_density_plot() {
1620        let mismatch = densityplot(&[1.0, 2.0], &[1.0], LineplotOptions::default());
1621        assert!(matches!(mismatch, Err(LineplotError::LengthMismatch)));
1622
1623        let empty = densityplot(&[] as &[f64], &[] as &[f64], LineplotOptions::default());
1624        assert!(matches!(empty, Err(LineplotError::EmptySeries)));
1625
1626        let invalid = densityplot(&["bad"], &["1"], LineplotOptions::default());
1627        assert!(matches!(
1628            invalid,
1629            Err(LineplotError::InvalidNumericValue { .. })
1630        ));
1631
1632        let mut non_density_plot =
1633            scatterplot(&[1.0, 2.0], &[1.0, 2.0], LineplotOptions::default())
1634                .expect("scatterplot should succeed");
1635        let add_err = densityplot_add(
1636            &mut non_density_plot,
1637            &[1.0, 2.0],
1638            &[1.0, 2.0],
1639            LineplotSeriesOptions::default(),
1640        );
1641        assert!(matches!(add_err, Err(LineplotError::DensityPlotRequired)));
1642
1643        let mut density_plot = densityplot(&[1.0, 2.0], &[1.0, 2.0], LineplotOptions::default())
1644            .expect("densityplot should succeed");
1645
1646        let add_mismatch = densityplot_add(
1647            &mut density_plot,
1648            &[1.0, 2.0],
1649            &[1.0],
1650            LineplotSeriesOptions::default(),
1651        );
1652        assert!(matches!(add_mismatch, Err(LineplotError::LengthMismatch)));
1653
1654        let add_empty = densityplot_add(
1655            &mut density_plot,
1656            &[] as &[f64],
1657            &[] as &[f64],
1658            LineplotSeriesOptions::default(),
1659        );
1660        assert!(matches!(add_empty, Err(LineplotError::EmptySeries)));
1661
1662        let add_invalid = densityplot_add(
1663            &mut density_plot,
1664            &["bad"],
1665            &["1"],
1666            LineplotSeriesOptions::default(),
1667        );
1668        assert!(matches!(
1669            add_invalid,
1670            Err(LineplotError::InvalidNumericValue { .. })
1671        ));
1672    }
1673
1674    #[test]
1675    fn densityplot_forces_density_canvas_and_disables_grid() {
1676        let x = [-1.0, 0.0, 1.0];
1677        let y = [-1.0, 0.0, 1.0];
1678
1679        let default_plot = densityplot(&x, &y, LineplotOptions::default())
1680            .expect("default densityplot should succeed");
1681        let forced_plot = densityplot(
1682            &x,
1683            &y,
1684            LineplotOptions {
1685                canvas: CanvasType::Dot,
1686                grid: true,
1687                ..LineplotOptions::default()
1688            },
1689        )
1690        .expect("forced densityplot should succeed");
1691
1692        assert!(matches!(default_plot.graphics(), GridCanvas::Density(_)));
1693        assert!(matches!(forced_plot.graphics(), GridCanvas::Density(_)));
1694        assert_eq!(
1695            render_plot_text(&forced_plot, false),
1696            render_plot_text(&default_plot, false)
1697        );
1698    }
1699
1700    #[test]
1701    fn densityplot_default_fixture() {
1702        let (dx, dy) = load_density_fixture_data();
1703        assert_eq!(dx.len(), 1000);
1704        assert_eq!(dy.len(), 1000);
1705
1706        let mut plot =
1707            densityplot(&dx, &dy, LineplotOptions::default()).expect("densityplot should succeed");
1708        assert!(matches!(plot.graphics(), GridCanvas::Density(_)));
1709
1710        let dx2: Vec<f64> = dx.iter().map(|value| value + 2.0).collect();
1711        let dy2: Vec<f64> = dy.iter().map(|value| value + 2.0).collect();
1712        densityplot_add(&mut plot, &dx2, &dy2, LineplotSeriesOptions::default())
1713            .expect("densityplot add should succeed");
1714
1715        assert_fixture_eq(
1716            &render_plot_text(&plot, true),
1717            "tests/fixtures/scatterplot/densityplot.txt",
1718        );
1719    }
1720
1721    #[test]
1722    fn densityplot_parameters_fixture() {
1723        let (dx, dy) = load_density_fixture_data();
1724
1725        let mut plot = densityplot(
1726            &dx,
1727            &dy,
1728            LineplotOptions {
1729                name: Some(String::from("foo")),
1730                color: Some(TermColor::Named(NamedColor::Red)),
1731                title: Some(String::from("Title")),
1732                xlabel: Some(String::from("x")),
1733                ..LineplotOptions::default()
1734            },
1735        )
1736        .expect("densityplot should succeed");
1737
1738        let dx2: Vec<f64> = dx.iter().map(|value| value + 2.0).collect();
1739        let dy2: Vec<f64> = dy.iter().map(|value| value + 2.0).collect();
1740        densityplot_add(
1741            &mut plot,
1742            &dx2,
1743            &dy2,
1744            LineplotSeriesOptions {
1745                name: Some(String::from("bar")),
1746                ..LineplotSeriesOptions::default()
1747            },
1748        )
1749        .expect("densityplot add should succeed");
1750
1751        assert_fixture_eq(
1752            &render_plot_text(&plot, true),
1753            "tests/fixtures/scatterplot/densityplot_parameters.txt",
1754        );
1755    }
1756}