use thiserror::Error;
use crate::DecorationPosition;
use crate::border::BorderType;
use crate::canvas::{
AsciiCanvas, BlockCanvas, BrailleCanvas, Canvas, CanvasType, DensityCanvas, DotCanvas,
};
use crate::color::{CanvasColor, NamedColor, TermColor, canvas_color_from_term};
use crate::math::{extend_limits, format_axis_value, usize_to_f64};
use crate::plot::Plot;
const MIN_WIDTH: usize = 5;
const MIN_HEIGHT: usize = 2;
const DEFAULT_HEIGHT: usize = 15;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum GridCanvas {
Ascii(AsciiCanvas),
Block(BlockCanvas),
Braille(BrailleCanvas),
Density(DensityCanvas),
Dot(DotCanvas),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum StairStyle {
Pre,
#[default]
Post,
}
impl Canvas for GridCanvas {
fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
match self {
Self::Ascii(canvas) => canvas.pixel(x, y, color),
Self::Block(canvas) => canvas.pixel(x, y, color),
Self::Braille(canvas) => canvas.pixel(x, y, color),
Self::Density(canvas) => canvas.pixel(x, y, color),
Self::Dot(canvas) => canvas.pixel(x, y, color),
}
}
fn glyph_at(&self, col: usize, row: usize) -> char {
match self {
Self::Ascii(canvas) => canvas.glyph_at(col, row),
Self::Block(canvas) => canvas.glyph_at(col, row),
Self::Braille(canvas) => canvas.glyph_at(col, row),
Self::Density(canvas) => canvas.glyph_at(col, row),
Self::Dot(canvas) => canvas.glyph_at(col, row),
}
}
fn color_at(&self, col: usize, row: usize) -> CanvasColor {
match self {
Self::Ascii(canvas) => canvas.color_at(col, row),
Self::Block(canvas) => canvas.color_at(col, row),
Self::Braille(canvas) => canvas.color_at(col, row),
Self::Density(canvas) => canvas.color_at(col, row),
Self::Dot(canvas) => canvas.color_at(col, row),
}
}
fn char_width(&self) -> usize {
match self {
Self::Ascii(canvas) => canvas.char_width(),
Self::Block(canvas) => canvas.char_width(),
Self::Braille(canvas) => canvas.char_width(),
Self::Density(canvas) => canvas.char_width(),
Self::Dot(canvas) => canvas.char_width(),
}
}
fn char_height(&self) -> usize {
match self {
Self::Ascii(canvas) => canvas.char_height(),
Self::Block(canvas) => canvas.char_height(),
Self::Braille(canvas) => canvas.char_height(),
Self::Density(canvas) => canvas.char_height(),
Self::Dot(canvas) => canvas.char_height(),
}
}
fn pixel_width(&self) -> usize {
match self {
Self::Ascii(canvas) => canvas.pixel_width(),
Self::Block(canvas) => canvas.pixel_width(),
Self::Braille(canvas) => canvas.pixel_width(),
Self::Density(canvas) => canvas.pixel_width(),
Self::Dot(canvas) => canvas.pixel_width(),
}
}
fn pixel_height(&self) -> usize {
match self {
Self::Ascii(canvas) => canvas.pixel_height(),
Self::Block(canvas) => canvas.pixel_height(),
Self::Braille(canvas) => canvas.pixel_height(),
Self::Density(canvas) => canvas.pixel_height(),
Self::Dot(canvas) => canvas.pixel_height(),
}
}
fn transform(&self) -> &crate::canvas::Transform2D {
match self {
Self::Ascii(canvas) => canvas.transform(),
Self::Block(canvas) => canvas.transform(),
Self::Braille(canvas) => canvas.transform(),
Self::Density(canvas) => canvas.transform(),
Self::Dot(canvas) => canvas.transform(),
}
}
fn transform_mut(&mut self) -> &mut crate::canvas::Transform2D {
match self {
Self::Ascii(canvas) => canvas.transform_mut(),
Self::Block(canvas) => canvas.transform_mut(),
Self::Braille(canvas) => canvas.transform_mut(),
Self::Density(canvas) => canvas.transform_mut(),
Self::Dot(canvas) => canvas.transform_mut(),
}
}
}
#[derive(Debug, Error, PartialEq)]
#[non_exhaustive]
pub enum LineplotError {
#[error("x and y must be the same length")]
LengthMismatch,
#[error("x and y must not be empty")]
EmptySeries,
#[error("axis limits must contain finite values")]
InvalidAxisLimits,
#[error("invalid numeric value: {value}")]
InvalidNumericValue { value: String },
#[error("densityplot_add requires a density plot")]
DensityPlotRequired,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct LineplotSeriesOptions {
pub color: Option<TermColor>,
pub name: Option<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct LineplotOptions {
pub title: Option<String>,
pub xlabel: Option<String>,
pub ylabel: Option<String>,
pub border: BorderType,
pub margin: u16,
pub padding: u16,
pub labels: bool,
pub width: usize,
pub height: usize,
pub xlim: (f64, f64),
pub ylim: (f64, f64),
pub canvas: CanvasType,
pub grid: bool,
pub color: Option<TermColor>,
pub name: Option<String>,
}
impl Default for LineplotOptions {
fn default() -> Self {
Self {
title: None,
xlabel: None,
ylabel: None,
border: BorderType::Solid,
margin: Plot::<GridCanvas>::DEFAULT_MARGIN,
padding: Plot::<GridCanvas>::DEFAULT_PADDING,
labels: true,
width: 40,
height: DEFAULT_HEIGHT,
xlim: (0.0, 0.0),
ylim: (0.0, 0.0),
canvas: CanvasType::Braille,
grid: true,
color: None,
name: None,
}
}
}
pub fn lineplot<X: ToString, Y: ToString>(
x: &[X],
y: &[Y],
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
build_lineplot(&x, &y, options)
}
pub fn lineplot_y<Y: ToString>(
y: &[Y],
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
let y = parse_numbers(y)?;
if y.is_empty() {
return Err(LineplotError::EmptySeries);
}
let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
build_lineplot(&x, &y, options)
}
pub fn scatterplot<X: ToString, Y: ToString>(
x: &[X],
y: &[Y],
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
build_scatterplot(&x, &y, options)
}
pub fn scatterplot_y<Y: ToString>(
y: &[Y],
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
let y = parse_numbers(y)?;
if y.is_empty() {
return Err(LineplotError::EmptySeries);
}
let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
build_scatterplot(&x, &y, options)
}
pub fn densityplot<X: ToString, Y: ToString>(
x: &[X],
y: &[Y],
mut options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
options.canvas = CanvasType::Density;
options.grid = false;
build_scatterplot(&x, &y, options)
}
pub fn stairs<X: ToString, Y: ToString>(
x: &[X],
y: &[Y],
style: StairStyle,
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
validate_series(&x, &y)?;
let (stair_x, stair_y) = compute_stair_lines(&x, &y, style);
build_lineplot(&stair_x, &stair_y, options)
}
pub fn lineplot_add<X: ToString, Y: ToString>(
plot: &mut Plot<GridCanvas>,
x: &[X],
y: &[Y],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
add_series(plot, &x, &y, options)
}
pub fn scatterplot_add<X: ToString, Y: ToString>(
plot: &mut Plot<GridCanvas>,
x: &[X],
y: &[Y],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
add_scatter_series(plot, &x, &y, options)
}
pub fn densityplot_add<X: ToString, Y: ToString>(
plot: &mut Plot<GridCanvas>,
x: &[X],
y: &[Y],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
add_density_series(plot, &x, &y, options)
}
pub fn lineplot_add_slope(
plot: &mut Plot<GridCanvas>,
intercept: f64,
slope: f64,
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
if !intercept.is_finite() {
return Err(LineplotError::InvalidNumericValue {
value: intercept.to_string(),
});
}
if !slope.is_finite() {
return Err(LineplotError::InvalidNumericValue {
value: slope.to_string(),
});
}
add_series(plot, &[intercept], &[slope], options)
}
pub fn stairs_add<X: ToString, Y: ToString>(
plot: &mut Plot<GridCanvas>,
x: &[X],
y: &[Y],
style: StairStyle,
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
let x = parse_numbers(x)?;
let y = parse_numbers(y)?;
validate_series(&x, &y)?;
let (stair_x, stair_y) = compute_stair_lines(&x, &y, style);
add_series(plot, &stair_x, &stair_y, options)
}
pub fn lineplot_add_y<Y: ToString>(
plot: &mut Plot<GridCanvas>,
y: &[Y],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
let y = parse_numbers(y)?;
if y.is_empty() {
return Err(LineplotError::EmptySeries);
}
let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
add_series(plot, &x, &y, options)
}
pub fn scatterplot_add_y<Y: ToString>(
plot: &mut Plot<GridCanvas>,
y: &[Y],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
let y = parse_numbers(y)?;
if y.is_empty() {
return Err(LineplotError::EmptySeries);
}
let x: Vec<f64> = (1..=y.len()).map(usize_to_f64).collect();
add_scatter_series(plot, &x, &y, options)
}
pub fn annotate(
plot: &mut Plot<GridCanvas>,
position: DecorationPosition,
text: impl Into<String>,
) {
plot.set_decoration(position, text);
}
fn compute_stair_lines(x: &[f64], y: &[f64], style: StairStyle) -> (Vec<f64>, Vec<f64>) {
let mut x_out = Vec::with_capacity(x.len().saturating_mul(2).saturating_sub(1));
let mut y_out = Vec::with_capacity(y.len().saturating_mul(2).saturating_sub(1));
x_out.push(x[0]);
y_out.push(y[0]);
for i in 1..x.len() {
match style {
StairStyle::Post => {
x_out.push(x[i]);
y_out.push(y[i - 1]);
x_out.push(x[i]);
y_out.push(y[i]);
}
StairStyle::Pre => {
x_out.push(x[i - 1]);
y_out.push(y[i]);
x_out.push(x[i]);
y_out.push(y[i]);
}
}
}
(x_out, y_out)
}
fn build_lineplot(
x: &[f64],
y: &[f64],
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
validate_series(x, y)?;
validate_limits(options.xlim, options.ylim)?;
let width = options.width.max(MIN_WIDTH);
let height = options.height.max(MIN_HEIGHT);
let (min_x, max_x) = extend_limits(x, options.xlim);
let (min_y, max_y) = extend_limits(y, options.ylim);
let canvas = create_canvas(
options.canvas,
width,
height,
min_x,
min_y,
max_x - min_x,
max_y - min_y,
);
let mut plot = Plot::new(canvas);
plot.title = options.title;
plot.xlabel = options.xlabel;
plot.ylabel = options.ylabel;
plot.border = options.border;
plot.margin = options.margin;
plot.padding = options.padding;
plot.show_labels = options.labels;
annotate_axes(&mut plot, min_x, max_x, min_y, max_y);
if options.grid {
draw_grid_lines(plot.graphics_mut(), min_x, max_x, min_y, max_y);
}
let series_options = LineplotSeriesOptions {
color: options.color,
name: options.name,
};
add_series(&mut plot, x, y, series_options)?;
Ok(plot)
}
fn build_scatterplot(
x: &[f64],
y: &[f64],
options: LineplotOptions,
) -> Result<Plot<GridCanvas>, LineplotError> {
validate_series(x, y)?;
validate_limits(options.xlim, options.ylim)?;
let width = options.width.max(MIN_WIDTH);
let height = options.height.max(MIN_HEIGHT);
let (min_x, max_x) = extend_limits(x, options.xlim);
let (min_y, max_y) = extend_limits(y, options.ylim);
let canvas = create_canvas(
options.canvas,
width,
height,
min_x,
min_y,
max_x - min_x,
max_y - min_y,
);
let mut plot = Plot::new(canvas);
plot.title = options.title;
plot.xlabel = options.xlabel;
plot.ylabel = options.ylabel;
plot.border = options.border;
plot.margin = options.margin;
plot.padding = options.padding;
plot.show_labels = options.labels;
annotate_axes(&mut plot, min_x, max_x, min_y, max_y);
if options.grid {
draw_grid_lines(plot.graphics_mut(), min_x, max_x, min_y, max_y);
}
let series_options = LineplotSeriesOptions {
color: options.color,
name: options.name,
};
add_scatter_series(&mut plot, x, y, series_options)?;
Ok(plot)
}
fn add_series(
plot: &mut Plot<GridCanvas>,
x: &[f64],
y: &[f64],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
validate_series(x, y)?;
let color = options
.color
.unwrap_or_else(|| TermColor::Named(plot.next_color()));
let canvas_color = canvas_color_from_term(color);
if x.len() == 1 {
let (slope_x, slope_y) = slope_segment_for_plot(plot, x[0], y[0]);
plot.graphics_mut().lines(&slope_x, &slope_y, canvas_color);
} else {
plot.graphics_mut().lines(x, y, canvas_color);
}
if let Some(name) = options.name.filter(|value| !value.is_empty()) {
annotate_next_right(plot, name, color);
}
Ok(())
}
fn add_scatter_series(
plot: &mut Plot<GridCanvas>,
x: &[f64],
y: &[f64],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
validate_series(x, y)?;
let color = options
.color
.unwrap_or_else(|| TermColor::Named(plot.next_color()));
let canvas_color = canvas_color_from_term(color);
plot.graphics_mut().points(x, y, canvas_color);
if let Some(name) = options.name.filter(|value| !value.is_empty()) {
annotate_next_right(plot, name, color);
}
Ok(())
}
fn add_density_series(
plot: &mut Plot<GridCanvas>,
x: &[f64],
y: &[f64],
options: LineplotSeriesOptions,
) -> Result<(), LineplotError> {
if !matches!(plot.graphics(), GridCanvas::Density(_)) {
return Err(LineplotError::DensityPlotRequired);
}
add_scatter_series(plot, x, y, options)
}
fn slope_segment_for_plot(
plot: &Plot<GridCanvas>,
intercept: f64,
slope: f64,
) -> ([f64; 2], [f64; 2]) {
let x_axis = plot.graphics().transform().x();
let min_x = x_axis.origin();
let max_x = x_axis.origin() + x_axis.span();
let slope_x = [min_x, max_x];
let slope_y = [intercept + min_x * slope, intercept + max_x * slope];
(slope_x, slope_y)
}
fn annotate_next_right(plot: &mut Plot<GridCanvas>, text: String, color: TermColor) {
for row in 0..plot.graphics().char_height() {
if !plot.annotations().right().contains_key(&row) {
plot.annotate_right(row, text, Some(color));
return;
}
}
}
fn draw_grid_lines(canvas: &mut GridCanvas, min_x: f64, max_x: f64, min_y: f64, max_y: f64) {
if min_y < 0.0 && 0.0 < max_y {
let x_steps = canvas.pixel_width().saturating_sub(1);
if x_steps > 0 {
let step = (max_x - min_x) / usize_to_f64(x_steps);
if step.is_finite() && step > 0.0 {
let mut value = min_x;
while value <= max_x {
canvas.point(value, 0.0, CanvasColor::NORMAL);
value += step;
}
canvas.point(max_x, 0.0, CanvasColor::NORMAL);
}
}
}
if min_x < 0.0 && 0.0 < max_x {
let y_steps = canvas.pixel_height().saturating_sub(1);
if y_steps > 0 {
let step = (max_y - min_y) / usize_to_f64(y_steps);
if step.is_finite() && step > 0.0 {
let mut value = min_y;
while value <= max_y {
canvas.point(0.0, value, CanvasColor::NORMAL);
value += step;
}
canvas.point(0.0, max_y, CanvasColor::NORMAL);
}
}
}
}
fn annotate_axes(plot: &mut Plot<GridCanvas>, min_x: f64, max_x: f64, min_y: f64, max_y: f64) {
let y_max = format_axis_value(max_y);
let y_min = format_axis_value(min_y);
plot.annotate_left(0, y_max, Some(TermColor::Named(NamedColor::LightBlack)));
plot.annotate_left(
plot.graphics().char_height().saturating_sub(1),
y_min,
Some(TermColor::Named(NamedColor::LightBlack)),
);
plot.set_decoration(crate::DecorationPosition::Bl, format_axis_value(min_x));
plot.set_decoration(crate::DecorationPosition::Br, format_axis_value(max_x));
}
fn validate_limits(xlim: (f64, f64), ylim: (f64, f64)) -> Result<(), LineplotError> {
if !xlim.0.is_finite() || !xlim.1.is_finite() || !ylim.0.is_finite() || !ylim.1.is_finite() {
return Err(LineplotError::InvalidAxisLimits);
}
Ok(())
}
fn validate_series(x: &[f64], y: &[f64]) -> Result<(), LineplotError> {
if x.len() != y.len() {
return Err(LineplotError::LengthMismatch);
}
if x.is_empty() {
return Err(LineplotError::EmptySeries);
}
Ok(())
}
fn parse_numbers<T: ToString>(values: &[T]) -> Result<Vec<f64>, LineplotError> {
values
.iter()
.map(|value| {
let display = value.to_string();
let numeric =
display
.parse::<f64>()
.map_err(|_| LineplotError::InvalidNumericValue {
value: display.clone(),
})?;
if !numeric.is_finite() {
return Err(LineplotError::InvalidNumericValue { value: display });
}
Ok(numeric)
})
.collect()
}
fn create_canvas(
canvas_type: CanvasType,
width: usize,
height: usize,
origin_x: f64,
origin_y: f64,
plot_width: f64,
plot_height: f64,
) -> GridCanvas {
match canvas_type {
CanvasType::Ascii => GridCanvas::Ascii(AsciiCanvas::new(
width,
height,
origin_x,
origin_y,
plot_width,
plot_height,
)),
CanvasType::Block => GridCanvas::Block(BlockCanvas::new(
width,
height,
origin_x,
origin_y,
plot_width,
plot_height,
)),
CanvasType::Braille => GridCanvas::Braille(BrailleCanvas::new(
width,
height,
origin_x,
origin_y,
plot_width,
plot_height,
)),
CanvasType::Density => GridCanvas::Density(DensityCanvas::new(
width,
height,
origin_x,
origin_y,
plot_width,
plot_height,
)),
CanvasType::Dot => GridCanvas::Dot(DotCanvas::new(
width,
height,
origin_x,
origin_y,
plot_width,
plot_height,
)),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use super::{
GridCanvas, LineplotError, LineplotOptions, LineplotSeriesOptions, StairStyle, annotate,
densityplot, densityplot_add, lineplot, lineplot_add, lineplot_add_slope, lineplot_add_y,
lineplot_y, scatterplot, scatterplot_add, scatterplot_add_y, scatterplot_y, stairs,
stairs_add,
};
use crate::DecorationPosition;
use crate::canvas::{Canvas, CanvasType};
use crate::color::{NamedColor, TermColor};
use crate::math::usize_to_f64;
use crate::parse_border_type;
use crate::test_util::{assert_fixture_eq, render_plot_text};
fn load_density_fixture_data() -> (Vec<f64>, Vec<f64>) {
let fixture_path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/data/randn_1338_2000.txt");
let values: Vec<f64> = fs::read_to_string(&fixture_path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", fixture_path.display()))
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.parse::<f64>().unwrap_or_else(|error| {
panic!("failed to parse density fixture value `{line}`: {error}")
})
})
.collect();
assert_eq!(
values.len(),
2000,
"density fixture should have 2000 values"
);
let dx = values[0..1000].to_vec();
let dy = values[1000..2000].to_vec();
(dx, dy)
}
fn assert_fixture_render(plot: &crate::Plot<GridCanvas>, color: bool, fixture: &str) {
assert_fixture_eq(&render_plot_text(plot, color), fixture);
}
fn base_x() -> Vec<i32> {
vec![-1, 1, 3, 3, -1]
}
fn base_y() -> Vec<i32> {
vec![2, 0, -5, 2, -5]
}
#[test]
fn validates_arguments_and_inputs() {
let x = [1, 2];
let y = [1, 2, 3];
let mismatch = lineplot(&x, &y, LineplotOptions::default());
assert!(matches!(mismatch, Err(LineplotError::LengthMismatch)));
let empty = lineplot(&[] as &[i32], &[] as &[i32], LineplotOptions::default());
assert!(matches!(empty, Err(LineplotError::EmptySeries)));
let invalid = lineplot(&["a"], &["1"], LineplotOptions::default());
assert!(matches!(
invalid,
Err(LineplotError::InvalidNumericValue { .. })
));
}
#[test]
fn default_fixture() {
let plot = lineplot(&base_x(), &base_y(), LineplotOptions::default())
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/default.txt");
}
#[test]
fn shared_renderer_matches_lineplot_default_fixture() {
let plot = lineplot(&base_x(), &base_y(), LineplotOptions::default())
.expect("lineplot should succeed");
let ansi_rendered = render_plot_text(&plot, true);
assert_fixture_eq(&ansi_rendered, "tests/fixtures/lineplot/default.txt");
let plain_rendered = render_plot_text(&plot, false);
assert!(!plain_rendered.contains("\x1b["));
}
#[test]
fn y_only_and_range_fixtures() {
let plot =
lineplot_y(&base_y(), LineplotOptions::default()).expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/y_only.txt");
let range1: Vec<i32> = (6..=10).collect();
let plot =
lineplot_y(&range1, LineplotOptions::default()).expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/range1.txt");
let x: Vec<i32> = (11..=15).collect();
let y: Vec<i32> = (6..=10).collect();
let plot = lineplot(&x, &y, LineplotOptions::default()).expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/range2.txt");
}
#[test]
fn limits_nogrid_and_canvas_size_fixtures() {
let plot = lineplot(
&base_x(),
&base_y(),
LineplotOptions {
xlim: (-1.5, 3.5),
ylim: (-5.5, 2.5),
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/limits.txt");
let plot = lineplot(
&base_x(),
&base_y(),
LineplotOptions {
grid: false,
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/nogrid.txt");
let plot = lineplot(
&base_x(),
&base_y(),
LineplotOptions {
title: Some(String::from("Scatter")),
canvas: CanvasType::Dot,
width: 10,
height: 5,
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/canvassize.txt");
}
#[test]
fn named_color_and_parameters_fixtures() {
let mut plot = lineplot(
&base_x(),
&base_y(),
LineplotOptions {
color: Some(TermColor::Named(NamedColor::Blue)),
name: Some(String::from("points1")),
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/blue.txt");
plot = lineplot(
&base_x(),
&base_y(),
LineplotOptions {
name: Some(String::from("points1")),
title: Some(String::from("Scatter")),
xlabel: Some(String::from("x")),
ylabel: Some(String::from("y")),
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/parameters1.txt");
lineplot_add_y(
&mut plot,
&[0.5, 1.0, 1.5],
LineplotSeriesOptions {
name: Some(String::from("points2")),
..LineplotSeriesOptions::default()
},
)
.expect("append should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/parameters2.txt");
lineplot_add(
&mut plot,
&[-0.5, 0.5, 1.5],
&[0.5, 1.0, 1.5],
LineplotSeriesOptions {
name: Some(String::from("points3")),
..LineplotSeriesOptions::default()
},
)
.expect("append should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/parameters3.txt");
assert_fixture_render(&plot, false, "tests/fixtures/lineplot/nocolor.txt");
}
#[test]
fn scale_and_issue32_fixtures() {
let x1: Vec<f64> = base_x()
.into_iter()
.map(|value| f64::from(value) * 1e3 + 15.0)
.collect();
let y1: Vec<f64> = base_y()
.into_iter()
.map(|value| f64::from(value) * 1e-3 - 15.0)
.collect();
let plot = lineplot(&x1, &y1, LineplotOptions::default()).expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale1.txt");
let x2: Vec<f64> = base_x()
.into_iter()
.map(|value| f64::from(value) * 1e-3 + 15.0)
.collect();
let y2: Vec<f64> = base_y()
.into_iter()
.map(|value| f64::from(value) * 1e3 - 15.0)
.collect();
let plot = lineplot(&x2, &y2, LineplotOptions::default()).expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale2.txt");
let tx = [-1.0, 2.0, 3.0, 700_000.0];
let ty = [1.0, 2.0, 9.0, 4_000_000.0];
let plot = lineplot(&tx, &ty, LineplotOptions::default()).expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale3.txt");
let plot = lineplot(
&tx,
&ty,
LineplotOptions {
width: 5,
height: 5,
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/scale3_small.txt");
let ys = [
261.0, 272.0, 277.0, 283.0, 289.0, 294.0, 298.0, 305.0, 309.0, 314.0, 319.0, 320.0,
322.0, 323.0, 324.0,
];
let xs: Vec<f64> = (0..ys.len()).map(usize_to_f64).collect();
let plot = lineplot(
&xs,
&ys,
LineplotOptions {
height: 26,
ylim: (0.0, 700.0),
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/issue32_fix.txt");
}
#[test]
fn stairs_pre_and_post_fixtures() {
let sx = [1, 2, 4, 7, 8];
let sy = [1, 3, 4, 2, 7];
let plot = stairs(&sx, &sy, StairStyle::Pre, LineplotOptions::default())
.expect("stairs should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_pre.txt");
let plot = stairs(&sx, &sy, StairStyle::Post, LineplotOptions::default())
.expect("stairs should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_post.txt");
}
#[test]
fn stairs_parameter_and_nocolor_fixtures() {
let sx = [1.0, 2.0, 4.0, 7.0, 8.0];
let sy = [1.0, 3.0, 4.0, 2.0, 7.0];
let mut plot = stairs(
&sx,
&sy,
StairStyle::Post,
LineplotOptions {
title: Some(String::from("Foo")),
color: Some(TermColor::Named(NamedColor::Red)),
xlabel: Some(String::from("x")),
name: Some(String::from("1")),
..LineplotOptions::default()
},
)
.expect("stairs should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_parameters.txt");
let sx2: Vec<f64> = sx.iter().map(|value| *value - 0.2).collect();
let sy2: Vec<f64> = sy.iter().map(|value| *value + 1.5).collect();
stairs_add(
&mut plot,
&sx2,
&sy2,
StairStyle::Post,
LineplotSeriesOptions {
name: Some(String::from("2")),
..LineplotSeriesOptions::default()
},
)
.expect("stairs add should succeed");
assert_fixture_render(
&plot,
true,
"tests/fixtures/lineplot/stairs_parameters2.txt",
);
assert_fixture_render(
&plot,
false,
"tests/fixtures/lineplot/stairs_parameters2_nocolor.txt",
);
stairs_add(
&mut plot,
&sx,
&sy,
StairStyle::Pre,
LineplotSeriesOptions {
name: Some(String::from("3")),
..LineplotSeriesOptions::default()
},
)
.expect("stairs add should succeed");
let rendered = render_plot_text(&plot, false);
assert!(rendered.contains("Foo"));
assert!(rendered.contains('1'));
assert!(rendered.contains('2'));
assert!(rendered.contains('3'));
}
#[test]
fn stairs_edgecase_fixture() {
let sx = [1, 2, 4, 7, 8];
let sy = [1, 3, 4, 2, 7000];
let plot = stairs(&sx, &sy, StairStyle::Post, LineplotOptions::default())
.expect("stairs should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/stairs_edgecase.txt");
}
#[test]
fn slope_fixtures() {
let mut plot =
lineplot_y(&base_y(), LineplotOptions::default()).expect("lineplot should succeed");
lineplot_add_slope(&mut plot, -3.0, 1.0, LineplotSeriesOptions::default())
.expect("lineplot add should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/slope1.txt");
lineplot_add(
&mut plot,
&[-4.0],
&[0.5],
LineplotSeriesOptions {
color: Some(TermColor::Named(NamedColor::Cyan)),
name: Some(String::from("foo")),
},
)
.expect("lineplot add should succeed");
assert_fixture_render(&plot, true, "tests/fixtures/lineplot/slope2.txt");
}
#[test]
fn slope_mode_uses_plot_x_axis_bounds() {
let mut plot = lineplot(
&[0.0, 1.0],
&[0.0, 1.0],
LineplotOptions {
xlim: (-2.0, 3.0),
ylim: (-2.0, 2.0),
..LineplotOptions::default()
},
)
.expect("lineplot should succeed");
lineplot_add_slope(&mut plot, 1.0, 0.5, LineplotSeriesOptions::default())
.expect("slope add should succeed");
let rendered = render_plot_text(&plot, false);
let has_bounds_line = rendered
.lines()
.any(|line| line.contains("-2") && line.contains('3'));
assert!(
has_bounds_line,
"expected axis bounds line in rendered plot"
);
}
#[test]
fn squeeze_annotations_fixture() {
let sx = [1, 2, 4, 7, 8];
let sy = [1, 3, 4, 2, 7];
let mut plot = stairs(
&sx,
&sy,
StairStyle::Post,
LineplotOptions {
width: 10,
padding: 3,
..LineplotOptions::default()
},
)
.expect("stairs should succeed");
annotate(&mut plot, DecorationPosition::Tl, "Hello");
annotate(&mut plot, DecorationPosition::T, "how are");
annotate(&mut plot, DecorationPosition::Tr, "you?");
annotate(&mut plot, DecorationPosition::Bl, "Hello");
annotate(&mut plot, DecorationPosition::B, "how are");
annotate(&mut plot, DecorationPosition::Br, "you?");
lineplot_add(&mut plot, &[1.0], &[0.5], LineplotSeriesOptions::default())
.expect("lineplot add should succeed");
assert_fixture_render(
&plot,
true,
"tests/fixtures/lineplot/squeeze_annotations.txt",
);
}
#[test]
fn scatterplot_validates_arguments_and_inputs() {
let x = [1, 2];
let y = [1, 2, 3];
let mismatch = scatterplot(&x, &y, LineplotOptions::default());
assert!(matches!(mismatch, Err(LineplotError::LengthMismatch)));
let empty = scatterplot(&[] as &[i32], &[] as &[i32], LineplotOptions::default());
assert!(matches!(empty, Err(LineplotError::EmptySeries)));
let invalid = scatterplot(&["a"], &["1"], LineplotOptions::default());
assert!(matches!(
invalid,
Err(LineplotError::InvalidNumericValue { .. })
));
let invalid_limits = scatterplot(
&[1],
&[1],
LineplotOptions {
xlim: (f64::NAN, 1.0),
..LineplotOptions::default()
},
);
assert!(matches!(
invalid_limits,
Err(LineplotError::InvalidAxisLimits)
));
let y_empty = scatterplot_y(&[] as &[i32], LineplotOptions::default());
assert!(matches!(y_empty, Err(LineplotError::EmptySeries)));
let y_invalid = scatterplot_y(&["NaN"], LineplotOptions::default());
assert!(matches!(
y_invalid,
Err(LineplotError::InvalidNumericValue { .. })
));
let mut base_plot = scatterplot(&[1.0, 2.0], &[1.0, 2.0], LineplotOptions::default())
.expect("base scatterplot should succeed");
let add_mismatch = scatterplot_add(
&mut base_plot,
&[1.0, 2.0],
&[1.0],
LineplotSeriesOptions::default(),
);
assert!(matches!(add_mismatch, Err(LineplotError::LengthMismatch)));
let add_y_empty = scatterplot_add_y(
&mut base_plot,
&[] as &[f64],
LineplotSeriesOptions::default(),
);
assert!(matches!(add_y_empty, Err(LineplotError::EmptySeries)));
let add_invalid = scatterplot_add(
&mut base_plot,
&["bad"],
&["1"],
LineplotSeriesOptions::default(),
);
assert!(matches!(
add_invalid,
Err(LineplotError::InvalidNumericValue { .. })
));
}
#[test]
fn scatterplot_draws_points_without_connecting_segments() {
let options = LineplotOptions {
canvas: CanvasType::Dot,
width: 10,
height: 5,
grid: false,
labels: false,
xlim: (0.0, 3.0),
ylim: (0.0, 3.0),
..LineplotOptions::default()
};
let line =
lineplot(&[0.0, 3.0], &[0.0, 3.0], options.clone()).expect("lineplot should succeed");
let scatter =
scatterplot(&[0.0, 3.0], &[0.0, 3.0], options).expect("scatterplot should succeed");
let line_cells = (0..line.graphics().char_height())
.flat_map(|row| line.graphics().row_cells(row))
.filter(|(glyph, _)| *glyph != ' ')
.count();
let scatter_cells = (0..scatter.graphics().char_height())
.flat_map(|row| scatter.graphics().row_cells(row))
.filter(|(glyph, _)| *glyph != ' ')
.count();
assert!(
line_cells > scatter_cells,
"lineplot should occupy more cells than scatterplot"
);
}
#[test]
fn scatterplot_default_y_and_range_fixtures() {
let plot = scatterplot(&base_x(), &base_y(), LineplotOptions::default())
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/default.txt",
);
let plot = scatterplot_y(&base_y(), LineplotOptions::default())
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/y_only.txt",
);
let range1: Vec<i32> = (6..=10).collect();
let plot =
scatterplot_y(&range1, LineplotOptions::default()).expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/range1.txt",
);
let x: Vec<i32> = (11..=15).collect();
let y: Vec<i32> = (6..=10).collect();
let plot =
scatterplot(&x, &y, LineplotOptions::default()).expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/range2.txt",
);
}
#[test]
fn scatterplot_scale_limits_and_nogrid_fixtures() {
let x1: Vec<f64> = base_x()
.into_iter()
.map(|value| f64::from(value) * 1e3 + 15.0)
.collect();
let y1: Vec<f64> = base_y()
.into_iter()
.map(|value| f64::from(value) * 1e-3 - 15.0)
.collect();
let plot =
scatterplot(&x1, &y1, LineplotOptions::default()).expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/scale1.txt",
);
let x2: Vec<f64> = base_x()
.into_iter()
.map(|value| f64::from(value) * 1e-3 + 15.0)
.collect();
let y2: Vec<f64> = base_y()
.into_iter()
.map(|value| f64::from(value) * 1e3 - 15.0)
.collect();
let plot =
scatterplot(&x2, &y2, LineplotOptions::default()).expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/scale2.txt",
);
let tx = [-1.0, 2.0, 3.0, 700_000.0];
let ty = [1.0, 2.0, 9.0, 4_000_000.0];
let plot =
scatterplot(&tx, &ty, LineplotOptions::default()).expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/scale3.txt",
);
let plot = scatterplot(
&base_x(),
&base_y(),
LineplotOptions {
xlim: (-1.5, 3.5),
ylim: (-5.5, 2.5),
..LineplotOptions::default()
},
)
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/limits.txt",
);
let plot = scatterplot(
&base_x(),
&base_y(),
LineplotOptions {
grid: false,
..LineplotOptions::default()
},
)
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/nogrid.txt",
);
}
#[test]
fn scatterplot_color_parameters_and_canvas_size_fixtures() {
let plot = scatterplot(
&base_x(),
&base_y(),
LineplotOptions {
color: Some(TermColor::Named(NamedColor::Blue)),
name: Some(String::from("points1")),
..LineplotOptions::default()
},
)
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/blue.txt",
);
let mut plot = scatterplot(
&base_x(),
&base_y(),
LineplotOptions {
name: Some(String::from("points1")),
title: Some(String::from("Scatter")),
xlabel: Some(String::from("x")),
ylabel: Some(String::from("y")),
..LineplotOptions::default()
},
)
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/parameters1.txt",
);
scatterplot_add_y(
&mut plot,
&[2.0, 0.5, -1.0, 1.0],
LineplotSeriesOptions {
name: Some(String::from("points2")),
..LineplotSeriesOptions::default()
},
)
.expect("scatterplot add should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/parameters2.txt",
);
scatterplot_add(
&mut plot,
&[-0.5, 1.0, 2.5],
&[0.5, 1.0, 1.5],
LineplotSeriesOptions {
name: Some(String::from("points3")),
..LineplotSeriesOptions::default()
},
)
.expect("scatterplot add should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/parameters3.txt",
);
assert_fixture_eq(
&render_plot_text(&plot, false),
"tests/fixtures/scatterplot/nocolor.txt",
);
let plot = scatterplot(
&base_x(),
&base_y(),
LineplotOptions {
title: Some(String::from("Scatter")),
canvas: CanvasType::Dot,
width: 10,
height: 5,
..LineplotOptions::default()
},
)
.expect("scatterplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/canvassize.txt",
);
}
#[test]
fn densityplot_unknown_border_name_errors() {
let err =
parse_border_type("invalid_border_name").expect_err("unknown border name should fail");
assert_eq!(
err,
crate::BarplotError::UnknownBorderType {
name: String::from("invalid_border_name")
}
);
}
#[test]
fn densityplot_validates_inputs_and_add_requires_density_plot() {
let mismatch = densityplot(&[1.0, 2.0], &[1.0], LineplotOptions::default());
assert!(matches!(mismatch, Err(LineplotError::LengthMismatch)));
let empty = densityplot(&[] as &[f64], &[] as &[f64], LineplotOptions::default());
assert!(matches!(empty, Err(LineplotError::EmptySeries)));
let invalid = densityplot(&["bad"], &["1"], LineplotOptions::default());
assert!(matches!(
invalid,
Err(LineplotError::InvalidNumericValue { .. })
));
let mut non_density_plot =
scatterplot(&[1.0, 2.0], &[1.0, 2.0], LineplotOptions::default())
.expect("scatterplot should succeed");
let add_err = densityplot_add(
&mut non_density_plot,
&[1.0, 2.0],
&[1.0, 2.0],
LineplotSeriesOptions::default(),
);
assert!(matches!(add_err, Err(LineplotError::DensityPlotRequired)));
let mut density_plot = densityplot(&[1.0, 2.0], &[1.0, 2.0], LineplotOptions::default())
.expect("densityplot should succeed");
let add_mismatch = densityplot_add(
&mut density_plot,
&[1.0, 2.0],
&[1.0],
LineplotSeriesOptions::default(),
);
assert!(matches!(add_mismatch, Err(LineplotError::LengthMismatch)));
let add_empty = densityplot_add(
&mut density_plot,
&[] as &[f64],
&[] as &[f64],
LineplotSeriesOptions::default(),
);
assert!(matches!(add_empty, Err(LineplotError::EmptySeries)));
let add_invalid = densityplot_add(
&mut density_plot,
&["bad"],
&["1"],
LineplotSeriesOptions::default(),
);
assert!(matches!(
add_invalid,
Err(LineplotError::InvalidNumericValue { .. })
));
}
#[test]
fn densityplot_forces_density_canvas_and_disables_grid() {
let x = [-1.0, 0.0, 1.0];
let y = [-1.0, 0.0, 1.0];
let default_plot = densityplot(&x, &y, LineplotOptions::default())
.expect("default densityplot should succeed");
let forced_plot = densityplot(
&x,
&y,
LineplotOptions {
canvas: CanvasType::Dot,
grid: true,
..LineplotOptions::default()
},
)
.expect("forced densityplot should succeed");
assert!(matches!(default_plot.graphics(), GridCanvas::Density(_)));
assert!(matches!(forced_plot.graphics(), GridCanvas::Density(_)));
assert_eq!(
render_plot_text(&forced_plot, false),
render_plot_text(&default_plot, false)
);
}
#[test]
fn densityplot_default_fixture() {
let (dx, dy) = load_density_fixture_data();
assert_eq!(dx.len(), 1000);
assert_eq!(dy.len(), 1000);
let mut plot =
densityplot(&dx, &dy, LineplotOptions::default()).expect("densityplot should succeed");
assert!(matches!(plot.graphics(), GridCanvas::Density(_)));
let dx2: Vec<f64> = dx.iter().map(|value| value + 2.0).collect();
let dy2: Vec<f64> = dy.iter().map(|value| value + 2.0).collect();
densityplot_add(&mut plot, &dx2, &dy2, LineplotSeriesOptions::default())
.expect("densityplot add should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/densityplot.txt",
);
}
#[test]
fn densityplot_parameters_fixture() {
let (dx, dy) = load_density_fixture_data();
let mut plot = densityplot(
&dx,
&dy,
LineplotOptions {
name: Some(String::from("foo")),
color: Some(TermColor::Named(NamedColor::Red)),
title: Some(String::from("Title")),
xlabel: Some(String::from("x")),
..LineplotOptions::default()
},
)
.expect("densityplot should succeed");
let dx2: Vec<f64> = dx.iter().map(|value| value + 2.0).collect();
let dy2: Vec<f64> = dy.iter().map(|value| value + 2.0).collect();
densityplot_add(
&mut plot,
&dx2,
&dy2,
LineplotSeriesOptions {
name: Some(String::from("bar")),
..LineplotSeriesOptions::default()
},
)
.expect("densityplot add should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/scatterplot/densityplot_parameters.txt",
);
}
}