use crate::border::BorderType;
use crate::canvas::Scale;
use crate::color::{CanvasColor, NamedColor, TermColor, canvas_color_from_term};
use crate::graphics::{GraphicsArea, RowBuffer, RowCell};
use crate::plot::Plot;
use thiserror::Error;
const MIN_WIDTH: usize = 10;
const WIDTH_PADDING_FOR_VALUES: usize = 7;
const DEFAULT_BAR_SYMBOL: char = '\u{25A0}';
const FRACTIONAL_BLOCKS: [char; 8] = [
'\u{258F}', '\u{258E}', '\u{258D}', '\u{258C}', '\u{258B}', '\u{258A}', '\u{2589}', '\u{2588}',
];
#[derive(Debug, Clone, PartialEq)]
struct BarValue {
numeric: f64,
display: String,
}
#[derive(Debug, Clone)]
pub struct BarplotGraphics {
bars: Vec<BarValue>,
max_transformed: f64,
max_value_width: usize,
width_chars: usize,
color: CanvasColor,
symbol: Option<char>,
xscale: Scale,
}
impl BarplotGraphics {
fn new(
bars: Vec<BarValue>,
width: usize,
color: CanvasColor,
symbol: Option<char>,
xscale: Scale,
) -> Self {
let max_value_width = bars
.iter()
.map(|bar| bar.display.chars().count())
.max()
.unwrap_or(1);
let width_chars = width
.max(max_value_width + WIDTH_PADDING_FOR_VALUES)
.max(MIN_WIDTH);
let max_transformed = bars
.iter()
.map(|bar| xscale.apply(bar.numeric))
.fold(f64::NEG_INFINITY, f64::max);
Self {
bars,
max_transformed,
max_value_width,
width_chars,
color,
symbol,
xscale,
}
}
fn add_rows(&mut self, bars: Vec<BarValue>) {
self.bars.extend(bars);
self.max_value_width = self
.bars
.iter()
.map(|bar| bar.display.chars().count())
.max()
.unwrap_or(1);
self.max_transformed = self
.bars
.iter()
.map(|bar| self.xscale.apply(bar.numeric))
.fold(f64::NEG_INFINITY, f64::max);
self.width_chars = self
.width_chars
.max(self.max_value_width + WIDTH_PADDING_FOR_VALUES)
.max(MIN_WIDTH);
}
fn max_bar_width(&self) -> usize {
self.width_chars
.saturating_sub(2 + self.max_value_width)
.max(1)
}
fn bar_span(&self, numeric_value: f64, max_bar_width: usize) -> f64 {
if self.max_transformed > 0.0 {
let value = self.xscale.apply(numeric_value).max(0.0);
let width_f64 = u32::try_from(max_bar_width)
.map(f64::from)
.unwrap_or(f64::from(u32::MAX));
(value / self.max_transformed) * width_f64
} else {
0.0
}
}
fn render_bar_text(&self, numeric_value: f64, max_bar_width: usize) -> String {
let max_width_f64 = u32::try_from(max_bar_width)
.map(f64::from)
.unwrap_or(f64::from(u32::MAX));
if let Some(symbol) = self.symbol {
let span = self.bar_span(numeric_value, max_bar_width).round();
let clamped = span.clamp(0.0, max_width_f64);
let mut out = String::new();
for index in 0..max_bar_width {
let Ok(index_u32) = u32::try_from(index) else {
break;
};
if f64::from(index_u32) < clamped {
out.push(symbol);
}
}
return out;
}
let span = self
.bar_span(numeric_value, max_bar_width)
.clamp(0.0, max_width_f64);
let full = span.floor();
let residual_steps = ((span - full) * 8.0).round();
let full_with_carry = if residual_steps >= 8.0 {
(full + 1.0).min(max_width_f64)
} else {
full
};
let mut out = String::new();
for index in 0..max_bar_width {
let Ok(index_u32) = u32::try_from(index) else {
break;
};
if f64::from(index_u32.saturating_add(1)) <= full_with_carry {
out.push('\u{2588}');
}
}
if (1.0..8.0).contains(&residual_steps) {
let threshold = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
let tail_index = threshold
.iter()
.position(|step| residual_steps <= *step)
.unwrap_or(6);
if out.chars().count() < max_bar_width {
out.push(FRACTIONAL_BLOCKS[tail_index]);
}
}
out
}
}
impl GraphicsArea for BarplotGraphics {
fn nrows(&self) -> usize {
self.bars.len()
}
fn ncols(&self) -> usize {
self.width_chars
}
fn render_row(&self, row: usize, out: &mut RowBuffer) {
out.clear();
out.resize(
self.width_chars,
RowCell {
glyph: ' ',
color: CanvasColor::NORMAL,
},
);
let Some(bar) = self.bars.get(row) else {
return;
};
let max_bar_width = self.max_bar_width();
let bar_text = self.render_bar_text(bar.numeric, max_bar_width);
let mut cursor = 0;
for glyph in bar_text.chars() {
if cursor >= self.width_chars {
return;
}
out[cursor] = RowCell {
glyph,
color: self.color,
};
cursor += 1;
}
if cursor < self.width_chars {
cursor += 1;
}
for glyph in bar.display.chars() {
if cursor >= self.width_chars {
break;
}
out[cursor] = RowCell {
glyph,
color: CanvasColor::NORMAL,
};
cursor += 1;
}
}
}
#[derive(Debug, Error, PartialEq)]
#[non_exhaustive]
pub enum BarplotError {
#[error("The given vectors must be of the same length")]
LengthMismatch,
#[error("All values have to be positive. Negative bars are not supported.")]
NegativeValuesUnsupported,
#[error("invalid numeric value: {value}")]
InvalidNumericValue { value: String },
#[error("Can't append empty array to barplot")]
EmptyAppend,
#[error("unknown border type: {name}")]
UnknownBorderType { name: String },
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct BarplotOptions {
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 color: TermColor,
pub width: usize,
pub symbol: Option<char>,
pub xscale: Scale,
}
impl Default for BarplotOptions {
fn default() -> Self {
Self {
title: None,
xlabel: None,
ylabel: None,
border: BorderType::Barplot,
margin: Plot::<BarplotGraphics>::DEFAULT_MARGIN,
padding: Plot::<BarplotGraphics>::DEFAULT_PADDING,
labels: true,
color: TermColor::Named(NamedColor::Green),
width: 40,
symbol: Some(DEFAULT_BAR_SYMBOL),
xscale: Scale::Identity,
}
}
}
pub fn parse_border_type(border: &str) -> Result<BorderType, BarplotError> {
match border {
"solid" => Ok(BorderType::Solid),
"corners" => Ok(BorderType::Corners),
"barplot" => Ok(BorderType::Barplot),
"ascii" => Ok(BorderType::Ascii),
_ => Err(BarplotError::UnknownBorderType {
name: border.to_owned(),
}),
}
}
pub fn barplot<L: ToString, V: ToString>(
labels: &[L],
values: &[V],
mut options: BarplotOptions,
) -> Result<Plot<BarplotGraphics>, BarplotError> {
if labels.len() != values.len() {
return Err(BarplotError::LengthMismatch);
}
let bars = parse_values(values)?;
if bars.iter().any(|bar| bar.numeric < 0.0) {
return Err(BarplotError::NegativeValuesUnsupported);
}
if options.xlabel.is_none() {
options.xlabel = scale_label(options.xscale);
}
let color = canvas_color_from_term(options.color);
let graphics = BarplotGraphics::new(bars, options.width, color, options.symbol, options.xscale);
let mut plot = Plot::new(graphics);
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;
for (row, label) in labels.iter().enumerate() {
plot.annotate_left(row, label.to_string(), None);
}
Ok(plot)
}
pub fn barplot_add<L: ToString, V: ToString>(
plot: &mut Plot<BarplotGraphics>,
labels: &[L],
values: &[V],
) -> Result<(), BarplotError> {
if labels.len() != values.len() {
return Err(BarplotError::LengthMismatch);
}
if labels.is_empty() {
return Err(BarplotError::EmptyAppend);
}
let bars = parse_values(values)?;
if bars.iter().any(|bar| bar.numeric < 0.0) {
return Err(BarplotError::NegativeValuesUnsupported);
}
let row_offset = plot.graphics().nrows();
plot.graphics_mut().add_rows(bars);
for (index, label) in labels.iter().enumerate() {
plot.annotate_left(row_offset + index, label.to_string(), None);
}
Ok(())
}
fn parse_values<V: ToString>(values: &[V]) -> Result<Vec<BarValue>, BarplotError> {
values
.iter()
.map(|value| {
let display = value.to_string();
let numeric =
display
.parse::<f64>()
.map_err(|_| BarplotError::InvalidNumericValue {
value: display.clone(),
})?;
if !numeric.is_finite() {
return Err(BarplotError::InvalidNumericValue { value: display });
}
Ok(BarValue { numeric, display })
})
.collect()
}
fn scale_label(scale: Scale) -> Option<String> {
let label = match scale {
Scale::Identity => return None,
Scale::Ln => "[ln]",
Scale::Log2 => "[log2]",
Scale::Log10 => "[log10]",
};
Some(label.to_owned())
}
#[cfg(test)]
mod tests {
use super::{BarplotError, BarplotOptions, barplot, barplot_add, parse_border_type};
use crate::color::{NamedColor, TermColor};
use crate::graphics::{GraphicsArea, RowBuffer};
use crate::test_util::{assert_fixture_eq, render_plot_text};
#[test]
fn errors_for_mismatched_or_negative_data() {
match barplot(&["a"], &[1.0, 2.0], BarplotOptions::default()) {
Ok(_) => panic!("length mismatch must fail"),
Err(err) => assert_eq!(err, BarplotError::LengthMismatch),
}
match barplot(&["a", "b"], &[-1.0, 2.0], BarplotOptions::default()) {
Ok(_) => panic!("negative values must fail"),
Err(err) => assert_eq!(err, BarplotError::NegativeValuesUnsupported),
}
}
#[test]
fn rejects_non_finite_numeric_values() {
match barplot(&["nan"], &["NaN"], BarplotOptions::default()) {
Ok(_) => panic!("NaN must be rejected"),
Err(BarplotError::InvalidNumericValue { .. }) => {}
Err(other) => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn fractional_mode_rounds_tail_overflow_to_full_block() {
let options = BarplotOptions {
symbol: None,
..BarplotOptions::default()
};
let plot = barplot(&["near-max", "max"], &["3.999", "4.0"], options)
.expect("barplot should succeed");
let max_bar_width = plot.graphics().max_bar_width();
let mut row = RowBuffer::new();
plot.graphics().render_row(0, &mut row);
let full_blocks = row
.iter()
.take_while(|cell| cell.glyph == '\u{2588}')
.count();
assert_eq!(full_blocks, max_bar_width);
}
#[test]
fn errors_for_unknown_border_name() {
let err =
parse_border_type("invalid_border_name").expect_err("unknown border name should fail");
assert_eq!(
err,
BarplotError::UnknownBorderType {
name: String::from("invalid_border_name")
}
);
}
#[test]
fn default_colored_fixture() {
let plot = barplot(&["bar", "foo"], &[23, 37], BarplotOptions::default())
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/default.txt",
);
}
#[test]
fn default_nocolor_fixture() {
let plot = barplot(&["bar", "foo"], &[23, 37], BarplotOptions::default())
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, false),
"tests/fixtures/barplot/nocolor.txt",
);
}
#[test]
fn mixed_fixture() {
let plot = barplot(
&["bar", "2.1", "foo"],
&["23.0", "10", "37.0"],
BarplotOptions::default(),
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/default_mixed.txt",
);
}
#[test]
fn xscale_log10_default_label_fixture() {
let options = BarplotOptions {
title: Some(String::from("Logscale Plot")),
xscale: crate::canvas::Scale::Log10,
..BarplotOptions::default()
};
let plot = barplot(&["a", "b", "c", "d", "e"], &[0, 1, 10, 100, 1000], options)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/log10.txt",
);
}
#[test]
fn xscale_log10_custom_label_fixture() {
let options = BarplotOptions {
title: Some(String::from("Logscale Plot")),
xlabel: Some(String::from("custom label")),
xscale: crate::canvas::Scale::Log10,
..BarplotOptions::default()
};
let plot = barplot(&["a", "b", "c", "d", "e"], &[0, 1, 10, 100, 1000], options)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/log10_label.txt",
);
}
#[test]
fn parameterized_fixtures() {
let options = BarplotOptions {
title: Some(String::from("Relative sizes of cities")),
xlabel: Some(String::from("population [in mil]")),
color: TermColor::Named(NamedColor::Blue),
margin: 7,
padding: 3,
..BarplotOptions::default()
};
let plot = barplot(
&["Paris", "New York", "Moskau", "Madrid"],
&[2.244, 8.406, 11.92, 3.165],
options,
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/parameters1.txt",
);
let options = BarplotOptions {
title: Some(String::from("Relative sizes of cities")),
xlabel: Some(String::from("population [in mil]")),
color: TermColor::Named(NamedColor::Blue),
margin: 7,
padding: 3,
labels: false,
..BarplotOptions::default()
};
let plot = barplot(
&["Paris", "New York", "Moskau", "Madrid"],
&[2.244, 8.406, 11.92, 3.165],
options,
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/parameters1_nolabels.txt",
);
let options = BarplotOptions {
title: Some(String::from("Relative sizes of cities")),
xlabel: Some(String::from("population [in mil]")),
color: TermColor::Named(NamedColor::Yellow),
border: crate::border::BorderType::Solid,
symbol: Some('='),
width: 60,
..BarplotOptions::default()
};
let plot = barplot(
&["Paris", "New York", "Moskau", "Madrid"],
&[2.244, 8.406, 11.92, 3.165],
options,
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/parameters2.txt",
);
}
#[test]
fn range_and_edge_case_fixtures() {
let plot = barplot(
&[2, 3, 4, 5, 6],
&[11, 12, 13, 14, 15],
BarplotOptions::default(),
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/ranges.txt",
);
let plot = barplot(
&[5, 4, 3, 2, 1],
&[0, 0, 0, 0, 0],
BarplotOptions::default(),
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/edgecase_zeros.txt",
);
let plot = barplot(
&["a", "b", "c", "d"],
&[1, 1, 1, 1_000_000],
BarplotOptions::default(),
)
.expect("barplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/edgecase_onelarge.txt",
);
}
#[test]
fn barplot_add_errors_and_fixtures() {
let mut plot = barplot(&["bar", "foo"], &[23, 37], BarplotOptions::default())
.expect("barplot should succeed");
let err = barplot_add(&mut plot, &["zoom"], &[90, 80])
.expect_err("mismatched append should fail");
assert_eq!(err, BarplotError::LengthMismatch);
let err = barplot_add(&mut plot, &[] as &[&str], &[] as &[i32])
.expect_err("empty append should fail");
assert_eq!(err, BarplotError::EmptyAppend);
barplot_add(&mut plot, &["zoom"], &[90]).expect("append should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/default2.txt",
);
let mut plot = barplot(
&[2, 3, 4, 5, 6],
&[11, 12, 13, 14, 15],
BarplotOptions::default(),
)
.expect("barplot should succeed");
barplot_add(&mut plot, &[9, 10], &[20, 21]).expect("append should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/barplot/ranges2.txt",
);
}
}