Skip to main content

unicode_plot/
barplot.rs

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/// Graphics area for horizontal bar charts with optional fractional block rendering.
23#[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/// Errors returned by barplot construction and mutation.
213#[derive(Debug, Error, PartialEq)]
214#[non_exhaustive]
215pub enum BarplotError {
216    /// Labels and values slices have different lengths.
217    #[error("The given vectors must be of the same length")]
218    LengthMismatch,
219    /// A bar value is negative (only non-negative values are supported).
220    #[error("All values have to be positive. Negative bars are not supported.")]
221    NegativeValuesUnsupported,
222    /// A value could not be parsed as a finite floating-point number.
223    #[error("invalid numeric value: {value}")]
224    InvalidNumericValue { value: String },
225    /// Attempted to append an empty label/value set.
226    #[error("Can't append empty array to barplot")]
227    EmptyAppend,
228    /// The border name string does not match any known border type.
229    #[error("unknown border type: {name}")]
230    UnknownBorderType { name: String },
231}
232
233/// Configuration for barplot construction.
234#[derive(Debug, Clone)]
235#[non_exhaustive]
236pub struct BarplotOptions {
237    /// Plot title displayed above the chart.
238    pub title: Option<String>,
239    /// Label below the x-axis.
240    pub xlabel: Option<String>,
241    /// Label to the left of the y-axis.
242    pub ylabel: Option<String>,
243    /// Border style (default: [`BorderType::Barplot`]).
244    pub border: BorderType,
245    /// Left margin in characters (default: 3).
246    pub margin: u16,
247    /// Padding between border and content (default: 1).
248    pub padding: u16,
249    /// Whether to show value labels to the right of each bar (default: true).
250    pub labels: bool,
251    /// Bar color (default: green).
252    pub color: TermColor,
253    /// Maximum bar width in characters (default: 40).
254    pub width: usize,
255    /// Custom single-character bar symbol. `None` uses fractional block characters.
256    pub symbol: Option<char>,
257    /// X-axis scale transform applied to bar lengths (default: identity).
258    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
279/// Parses a border name into a [`BorderType`].
280///
281/// # Errors
282///
283/// Returns [`BarplotError::UnknownBorderType`] when `border` is not one of
284/// `solid`, `corners`, `barplot`, or `ascii`.
285pub 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
297/// Constructs a bar plot from labels and values.
298///
299/// # Examples
300///
301/// ```
302/// use unicode_plot::{barplot, BarplotOptions};
303///
304/// let plot = barplot(
305///     &["apple", "banana", "cherry"],
306///     &[15, 42, 8],
307///     BarplotOptions::default(),
308/// ).unwrap();
309///
310/// let mut buf = Vec::new();
311/// plot.render(&mut buf, false).unwrap();
312/// assert!(!buf.is_empty());
313/// ```
314///
315/// # Errors
316///
317/// Returns [`BarplotError::LengthMismatch`] when input lengths differ,
318/// [`BarplotError::NegativeValuesUnsupported`] when a value is negative, and
319/// [`BarplotError::InvalidNumericValue`] if a value cannot be parsed as `f64`.
320pub 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
357/// Appends rows to an existing bar plot.
358///
359/// # Errors
360///
361/// Returns [`BarplotError::LengthMismatch`] when input lengths differ,
362/// [`BarplotError::EmptyAppend`] when no rows are provided,
363/// [`BarplotError::NegativeValuesUnsupported`] when a value is negative, and
364/// [`BarplotError::InvalidNumericValue`] if a value cannot be parsed as `f64`.
365pub 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}