1use crate::border::BorderType;
2use crate::canvas::Scale;
3use crate::color::{CanvasColor, NamedColor, TermColor, canvas_color_from_term};
4use crate::graphics::{GraphicsArea, RowBuffer, RowCell};
5use crate::plot::Plot;
6
7use thiserror::Error;
8
9const MIN_WIDTH: usize = 10;
10const WIDTH_PADDING_FOR_VALUES: usize = 7;
11const DEFAULT_BAR_SYMBOL: char = '\u{25A0}';
12const FRACTIONAL_BLOCKS: [char; 8] = [
13 '\u{258F}', '\u{258E}', '\u{258D}', '\u{258C}', '\u{258B}', '\u{258A}', '\u{2589}', '\u{2588}',
14];
15
16#[derive(Debug, Clone, PartialEq)]
17struct BarValue {
18 numeric: f64,
19 display: String,
20}
21
22#[derive(Debug, Clone)]
24pub struct BarplotGraphics {
25 bars: Vec<BarValue>,
26 max_transformed: f64,
27 max_value_width: usize,
28 width_chars: usize,
29 color: CanvasColor,
30 symbol: Option<char>,
31 xscale: Scale,
32}
33
34impl BarplotGraphics {
35 fn new(
36 bars: Vec<BarValue>,
37 width: usize,
38 color: CanvasColor,
39 symbol: Option<char>,
40 xscale: Scale,
41 ) -> Self {
42 let max_value_width = bars
43 .iter()
44 .map(|bar| bar.display.chars().count())
45 .max()
46 .unwrap_or(1);
47 let width_chars = width
48 .max(max_value_width + WIDTH_PADDING_FOR_VALUES)
49 .max(MIN_WIDTH);
50 let max_transformed = bars
51 .iter()
52 .map(|bar| xscale.apply(bar.numeric))
53 .fold(f64::NEG_INFINITY, f64::max);
54
55 Self {
56 bars,
57 max_transformed,
58 max_value_width,
59 width_chars,
60 color,
61 symbol,
62 xscale,
63 }
64 }
65
66 fn add_rows(&mut self, bars: Vec<BarValue>) {
67 self.bars.extend(bars);
68 self.max_value_width = self
69 .bars
70 .iter()
71 .map(|bar| bar.display.chars().count())
72 .max()
73 .unwrap_or(1);
74 self.max_transformed = self
75 .bars
76 .iter()
77 .map(|bar| self.xscale.apply(bar.numeric))
78 .fold(f64::NEG_INFINITY, f64::max);
79 self.width_chars = self
80 .width_chars
81 .max(self.max_value_width + WIDTH_PADDING_FOR_VALUES)
82 .max(MIN_WIDTH);
83 }
84
85 fn max_bar_width(&self) -> usize {
86 self.width_chars
87 .saturating_sub(2 + self.max_value_width)
88 .max(1)
89 }
90
91 fn bar_span(&self, numeric_value: f64, max_bar_width: usize) -> f64 {
92 if self.max_transformed > 0.0 {
93 let value = self.xscale.apply(numeric_value).max(0.0);
94 let width_f64 = u32::try_from(max_bar_width)
95 .map(f64::from)
96 .unwrap_or(f64::from(u32::MAX));
97 (value / self.max_transformed) * width_f64
98 } else {
99 0.0
100 }
101 }
102
103 fn render_bar_text(&self, numeric_value: f64, max_bar_width: usize) -> String {
104 let max_width_f64 = u32::try_from(max_bar_width)
105 .map(f64::from)
106 .unwrap_or(f64::from(u32::MAX));
107
108 if let Some(symbol) = self.symbol {
109 let span = self.bar_span(numeric_value, max_bar_width).round();
110 let clamped = span.clamp(0.0, max_width_f64);
111 let mut out = String::new();
112 for index in 0..max_bar_width {
113 let Ok(index_u32) = u32::try_from(index) else {
114 break;
115 };
116 if f64::from(index_u32) < clamped {
117 out.push(symbol);
118 }
119 }
120 return out;
121 }
122
123 let span = self
124 .bar_span(numeric_value, max_bar_width)
125 .clamp(0.0, max_width_f64);
126 let full = span.floor();
127 let residual_steps = ((span - full) * 8.0).round();
128 let full_with_carry = if residual_steps >= 8.0 {
129 (full + 1.0).min(max_width_f64)
130 } else {
131 full
132 };
133 let mut out = String::new();
134
135 for index in 0..max_bar_width {
136 let Ok(index_u32) = u32::try_from(index) else {
137 break;
138 };
139 if f64::from(index_u32.saturating_add(1)) <= full_with_carry {
140 out.push('\u{2588}');
141 }
142 }
143
144 if (1.0..8.0).contains(&residual_steps) {
145 let threshold = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
146 let tail_index = threshold
147 .iter()
148 .position(|step| residual_steps <= *step)
149 .unwrap_or(6);
150 if out.chars().count() < max_bar_width {
151 out.push(FRACTIONAL_BLOCKS[tail_index]);
152 }
153 }
154 out
155 }
156}
157
158impl GraphicsArea for BarplotGraphics {
159 fn nrows(&self) -> usize {
160 self.bars.len()
161 }
162
163 fn ncols(&self) -> usize {
164 self.width_chars
165 }
166
167 fn render_row(&self, row: usize, out: &mut RowBuffer) {
168 out.clear();
169 out.resize(
170 self.width_chars,
171 RowCell {
172 glyph: ' ',
173 color: CanvasColor::NORMAL,
174 },
175 );
176
177 let Some(bar) = self.bars.get(row) else {
178 return;
179 };
180 let max_bar_width = self.max_bar_width();
181 let bar_text = self.render_bar_text(bar.numeric, max_bar_width);
182
183 let mut cursor = 0;
184 for glyph in bar_text.chars() {
185 if cursor >= self.width_chars {
186 return;
187 }
188 out[cursor] = RowCell {
189 glyph,
190 color: self.color,
191 };
192 cursor += 1;
193 }
194
195 if cursor < self.width_chars {
196 cursor += 1;
197 }
198
199 for glyph in bar.display.chars() {
200 if cursor >= self.width_chars {
201 break;
202 }
203 out[cursor] = RowCell {
204 glyph,
205 color: CanvasColor::NORMAL,
206 };
207 cursor += 1;
208 }
209 }
210}
211
212#[derive(Debug, Error, PartialEq)]
214#[non_exhaustive]
215pub enum BarplotError {
216 #[error("The given vectors must be of the same length")]
218 LengthMismatch,
219 #[error("All values have to be positive. Negative bars are not supported.")]
221 NegativeValuesUnsupported,
222 #[error("invalid numeric value: {value}")]
224 InvalidNumericValue { value: String },
225 #[error("Can't append empty array to barplot")]
227 EmptyAppend,
228 #[error("unknown border type: {name}")]
230 UnknownBorderType { name: String },
231}
232
233#[derive(Debug, Clone)]
235#[non_exhaustive]
236pub struct BarplotOptions {
237 pub title: Option<String>,
239 pub xlabel: Option<String>,
241 pub ylabel: Option<String>,
243 pub border: BorderType,
245 pub margin: u16,
247 pub padding: u16,
249 pub labels: bool,
251 pub color: TermColor,
253 pub width: usize,
255 pub symbol: Option<char>,
257 pub xscale: Scale,
259}
260
261impl Default for BarplotOptions {
262 fn default() -> Self {
263 Self {
264 title: None,
265 xlabel: None,
266 ylabel: None,
267 border: BorderType::Barplot,
268 margin: Plot::<BarplotGraphics>::DEFAULT_MARGIN,
269 padding: Plot::<BarplotGraphics>::DEFAULT_PADDING,
270 labels: true,
271 color: TermColor::Named(NamedColor::Green),
272 width: 40,
273 symbol: Some(DEFAULT_BAR_SYMBOL),
274 xscale: Scale::Identity,
275 }
276 }
277}
278
279pub fn parse_border_type(border: &str) -> Result<BorderType, BarplotError> {
286 match border {
287 "solid" => Ok(BorderType::Solid),
288 "corners" => Ok(BorderType::Corners),
289 "barplot" => Ok(BorderType::Barplot),
290 "ascii" => Ok(BorderType::Ascii),
291 _ => Err(BarplotError::UnknownBorderType {
292 name: border.to_owned(),
293 }),
294 }
295}
296
297pub fn barplot<L: ToString, V: ToString>(
321 labels: &[L],
322 values: &[V],
323 mut options: BarplotOptions,
324) -> Result<Plot<BarplotGraphics>, BarplotError> {
325 if labels.len() != values.len() {
326 return Err(BarplotError::LengthMismatch);
327 }
328
329 let bars = parse_values(values)?;
330 if bars.iter().any(|bar| bar.numeric < 0.0) {
331 return Err(BarplotError::NegativeValuesUnsupported);
332 }
333
334 if options.xlabel.is_none() {
335 options.xlabel = scale_label(options.xscale);
336 }
337
338 let color = canvas_color_from_term(options.color);
339 let graphics = BarplotGraphics::new(bars, options.width, color, options.symbol, options.xscale);
340
341 let mut plot = Plot::new(graphics);
342 plot.title = options.title;
343 plot.xlabel = options.xlabel;
344 plot.ylabel = options.ylabel;
345 plot.border = options.border;
346 plot.margin = options.margin;
347 plot.padding = options.padding;
348 plot.show_labels = options.labels;
349
350 for (row, label) in labels.iter().enumerate() {
351 plot.annotate_left(row, label.to_string(), None);
352 }
353
354 Ok(plot)
355}
356
357pub fn barplot_add<L: ToString, V: ToString>(
366 plot: &mut Plot<BarplotGraphics>,
367 labels: &[L],
368 values: &[V],
369) -> Result<(), BarplotError> {
370 if labels.len() != values.len() {
371 return Err(BarplotError::LengthMismatch);
372 }
373 if labels.is_empty() {
374 return Err(BarplotError::EmptyAppend);
375 }
376
377 let bars = parse_values(values)?;
378 if bars.iter().any(|bar| bar.numeric < 0.0) {
379 return Err(BarplotError::NegativeValuesUnsupported);
380 }
381
382 let row_offset = plot.graphics().nrows();
383 plot.graphics_mut().add_rows(bars);
384 for (index, label) in labels.iter().enumerate() {
385 plot.annotate_left(row_offset + index, label.to_string(), None);
386 }
387
388 Ok(())
389}
390
391fn parse_values<V: ToString>(values: &[V]) -> Result<Vec<BarValue>, BarplotError> {
392 values
393 .iter()
394 .map(|value| {
395 let display = value.to_string();
396 let numeric =
397 display
398 .parse::<f64>()
399 .map_err(|_| BarplotError::InvalidNumericValue {
400 value: display.clone(),
401 })?;
402 if !numeric.is_finite() {
403 return Err(BarplotError::InvalidNumericValue { value: display });
404 }
405 Ok(BarValue { numeric, display })
406 })
407 .collect()
408}
409
410fn scale_label(scale: Scale) -> Option<String> {
411 let label = match scale {
412 Scale::Identity => return None,
413 Scale::Ln => "[ln]",
414 Scale::Log2 => "[log2]",
415 Scale::Log10 => "[log10]",
416 };
417 Some(label.to_owned())
418}
419
420#[cfg(test)]
421mod tests {
422 use super::{BarplotError, BarplotOptions, barplot, barplot_add, parse_border_type};
423 use crate::color::{NamedColor, TermColor};
424 use crate::graphics::{GraphicsArea, RowBuffer};
425 use crate::test_util::{assert_fixture_eq, render_plot_text};
426
427 #[test]
428 fn errors_for_mismatched_or_negative_data() {
429 match barplot(&["a"], &[1.0, 2.0], BarplotOptions::default()) {
430 Ok(_) => panic!("length mismatch must fail"),
431 Err(err) => assert_eq!(err, BarplotError::LengthMismatch),
432 }
433
434 match barplot(&["a", "b"], &[-1.0, 2.0], BarplotOptions::default()) {
435 Ok(_) => panic!("negative values must fail"),
436 Err(err) => assert_eq!(err, BarplotError::NegativeValuesUnsupported),
437 }
438 }
439
440 #[test]
441 fn rejects_non_finite_numeric_values() {
442 match barplot(&["nan"], &["NaN"], BarplotOptions::default()) {
443 Ok(_) => panic!("NaN must be rejected"),
444 Err(BarplotError::InvalidNumericValue { .. }) => {}
445 Err(other) => panic!("unexpected error variant: {other}"),
446 }
447 }
448
449 #[test]
450 fn fractional_mode_rounds_tail_overflow_to_full_block() {
451 let options = BarplotOptions {
452 symbol: None,
453 ..BarplotOptions::default()
454 };
455 let plot = barplot(&["near-max", "max"], &["3.999", "4.0"], options)
456 .expect("barplot should succeed");
457
458 let max_bar_width = plot.graphics().max_bar_width();
459 let mut row = RowBuffer::new();
460 plot.graphics().render_row(0, &mut row);
461
462 let full_blocks = row
463 .iter()
464 .take_while(|cell| cell.glyph == '\u{2588}')
465 .count();
466 assert_eq!(full_blocks, max_bar_width);
467 }
468
469 #[test]
470 fn errors_for_unknown_border_name() {
471 let err =
472 parse_border_type("invalid_border_name").expect_err("unknown border name should fail");
473 assert_eq!(
474 err,
475 BarplotError::UnknownBorderType {
476 name: String::from("invalid_border_name")
477 }
478 );
479 }
480
481 #[test]
482 fn default_colored_fixture() {
483 let plot = barplot(&["bar", "foo"], &[23, 37], BarplotOptions::default())
484 .expect("barplot should succeed");
485 assert_fixture_eq(
486 &render_plot_text(&plot, true),
487 "tests/fixtures/barplot/default.txt",
488 );
489 }
490
491 #[test]
492 fn default_nocolor_fixture() {
493 let plot = barplot(&["bar", "foo"], &[23, 37], BarplotOptions::default())
494 .expect("barplot should succeed");
495 assert_fixture_eq(
496 &render_plot_text(&plot, false),
497 "tests/fixtures/barplot/nocolor.txt",
498 );
499 }
500
501 #[test]
502 fn mixed_fixture() {
503 let plot = barplot(
504 &["bar", "2.1", "foo"],
505 &["23.0", "10", "37.0"],
506 BarplotOptions::default(),
507 )
508 .expect("barplot should succeed");
509 assert_fixture_eq(
510 &render_plot_text(&plot, true),
511 "tests/fixtures/barplot/default_mixed.txt",
512 );
513 }
514
515 #[test]
516 fn xscale_log10_default_label_fixture() {
517 let options = BarplotOptions {
518 title: Some(String::from("Logscale Plot")),
519 xscale: crate::canvas::Scale::Log10,
520 ..BarplotOptions::default()
521 };
522 let plot = barplot(&["a", "b", "c", "d", "e"], &[0, 1, 10, 100, 1000], options)
523 .expect("barplot should succeed");
524 assert_fixture_eq(
525 &render_plot_text(&plot, true),
526 "tests/fixtures/barplot/log10.txt",
527 );
528 }
529
530 #[test]
531 fn xscale_log10_custom_label_fixture() {
532 let options = BarplotOptions {
533 title: Some(String::from("Logscale Plot")),
534 xlabel: Some(String::from("custom label")),
535 xscale: crate::canvas::Scale::Log10,
536 ..BarplotOptions::default()
537 };
538 let plot = barplot(&["a", "b", "c", "d", "e"], &[0, 1, 10, 100, 1000], options)
539 .expect("barplot should succeed");
540 assert_fixture_eq(
541 &render_plot_text(&plot, true),
542 "tests/fixtures/barplot/log10_label.txt",
543 );
544 }
545
546 #[test]
547 fn parameterized_fixtures() {
548 let options = BarplotOptions {
549 title: Some(String::from("Relative sizes of cities")),
550 xlabel: Some(String::from("population [in mil]")),
551 color: TermColor::Named(NamedColor::Blue),
552 margin: 7,
553 padding: 3,
554 ..BarplotOptions::default()
555 };
556 let plot = barplot(
557 &["Paris", "New York", "Moskau", "Madrid"],
558 &[2.244, 8.406, 11.92, 3.165],
559 options,
560 )
561 .expect("barplot should succeed");
562 assert_fixture_eq(
563 &render_plot_text(&plot, true),
564 "tests/fixtures/barplot/parameters1.txt",
565 );
566
567 let options = BarplotOptions {
568 title: Some(String::from("Relative sizes of cities")),
569 xlabel: Some(String::from("population [in mil]")),
570 color: TermColor::Named(NamedColor::Blue),
571 margin: 7,
572 padding: 3,
573 labels: false,
574 ..BarplotOptions::default()
575 };
576 let plot = barplot(
577 &["Paris", "New York", "Moskau", "Madrid"],
578 &[2.244, 8.406, 11.92, 3.165],
579 options,
580 )
581 .expect("barplot should succeed");
582 assert_fixture_eq(
583 &render_plot_text(&plot, true),
584 "tests/fixtures/barplot/parameters1_nolabels.txt",
585 );
586
587 let options = BarplotOptions {
588 title: Some(String::from("Relative sizes of cities")),
589 xlabel: Some(String::from("population [in mil]")),
590 color: TermColor::Named(NamedColor::Yellow),
591 border: crate::border::BorderType::Solid,
592 symbol: Some('='),
593 width: 60,
594 ..BarplotOptions::default()
595 };
596 let plot = barplot(
597 &["Paris", "New York", "Moskau", "Madrid"],
598 &[2.244, 8.406, 11.92, 3.165],
599 options,
600 )
601 .expect("barplot should succeed");
602 assert_fixture_eq(
603 &render_plot_text(&plot, true),
604 "tests/fixtures/barplot/parameters2.txt",
605 );
606 }
607
608 #[test]
609 fn range_and_edge_case_fixtures() {
610 let plot = barplot(
611 &[2, 3, 4, 5, 6],
612 &[11, 12, 13, 14, 15],
613 BarplotOptions::default(),
614 )
615 .expect("barplot should succeed");
616 assert_fixture_eq(
617 &render_plot_text(&plot, true),
618 "tests/fixtures/barplot/ranges.txt",
619 );
620
621 let plot = barplot(
622 &[5, 4, 3, 2, 1],
623 &[0, 0, 0, 0, 0],
624 BarplotOptions::default(),
625 )
626 .expect("barplot should succeed");
627 assert_fixture_eq(
628 &render_plot_text(&plot, true),
629 "tests/fixtures/barplot/edgecase_zeros.txt",
630 );
631
632 let plot = barplot(
633 &["a", "b", "c", "d"],
634 &[1, 1, 1, 1_000_000],
635 BarplotOptions::default(),
636 )
637 .expect("barplot should succeed");
638 assert_fixture_eq(
639 &render_plot_text(&plot, true),
640 "tests/fixtures/barplot/edgecase_onelarge.txt",
641 );
642 }
643
644 #[test]
645 fn barplot_add_errors_and_fixtures() {
646 let mut plot = barplot(&["bar", "foo"], &[23, 37], BarplotOptions::default())
647 .expect("barplot should succeed");
648
649 let err = barplot_add(&mut plot, &["zoom"], &[90, 80])
650 .expect_err("mismatched append should fail");
651 assert_eq!(err, BarplotError::LengthMismatch);
652
653 let err = barplot_add(&mut plot, &[] as &[&str], &[] as &[i32])
654 .expect_err("empty append should fail");
655 assert_eq!(err, BarplotError::EmptyAppend);
656
657 barplot_add(&mut plot, &["zoom"], &[90]).expect("append should succeed");
658 assert_fixture_eq(
659 &render_plot_text(&plot, true),
660 "tests/fixtures/barplot/default2.txt",
661 );
662
663 let mut plot = barplot(
664 &[2, 3, 4, 5, 6],
665 &[11, 12, 13, 14, 15],
666 BarplotOptions::default(),
667 )
668 .expect("barplot should succeed");
669 barplot_add(&mut plot, &[9, 10], &[20, 21]).expect("append should succeed");
670 assert_fixture_eq(
671 &render_plot_text(&plot, true),
672 "tests/fixtures/barplot/ranges2.txt",
673 );
674 }
675}