Skip to main content

chart_relative/
lib.rs

1//! Compact bar charts in the terminal.
2//!
3//! # Examples
4//! labeled comparison
5//! ```
6//! use chart_relative::{params::*, Chart};
7//!
8//! let chart = Chart::new(
9//!    &[
10//!        0, 22, 2, 9, 223, 34, 33, 66, 76, 122, 199, 33, 12, 89, 1222, 100,
11//!    ],
12//!    Some(ChartComparison {
13//!        data: &[
14//!            14, 20, 1, 8, 223, 12, 56, 79, 69, 100, 1122, 33, 45, 9, 9000, 78,
15//!        ],
16//!    }),
17//!    ChartOptions {
18//!        height: 16,
19//!        view: ViewPreference::Bottom,
20//!        display: DisplayMode::Portrait {
21//!            labels: &[
22//!                "first", "second", "third", "fourth", "fifth", "sixth", "seventh",
23//!                "eighth", "nineth", "tenth", "eleventh", "twelfth", "thirteenth",
24//!                "fourteenth", "fifteenth", "sixteenth",
25//!            ],
26//!        },
27//!    },
28//!);
29//!println!("{chart}");
30//! ```
31//! output (with color)
32//! ```text
33//! 122│            🢁🢁             ▇  🢁🢁          🢁🢁
34//!    │            🢁🢁             █  🢁🢁          🢁🢁
35//!    │            🢁🢁             █  🢁🢁          🢁🢁
36//!    │            🢁🢁             ██ 🢁🢁          🢁🢁 █
37//!    │            🢁🢁             ██ 🢁🢁       ▅  🢁🢁 █
38//!    │            🢁🢁        ▂    ██ 🢁🢁       █  🢁🢁 █▁
39//!    │            🢁🢁        █ ▇  ██ 🢁🢁       █  🢁🢁 ██
40//!    │            🢁🢁       ▅█ ██ ██ 🢁🢁       █  🢁🢁 ██
41//!    │            🢁🢁     ▂ ██ ██ ██ 🢁🢁       █  🢁🢁 ██
42//!    │            🢁🢁     █ ██ ██ ██ 🢁🢁       █  🢁🢁 ██
43//!    │            🢁🢁     █ ██ ██ ██ 🢁🢁     ▇ █  🢁🢁 ██
44//!    │            🢁🢁 ▃  ▂█ ██ ██ ██ 🢁🢁 ▂▂  █ █  🢁🢁 ██
45//!    │            🢁🢁 █  ██ ██ ██ ██ 🢁🢁 ██  █ █  🢁🢁 ██
46//!    │   ▇▄       🢁🢁 █  ██ ██ ██ ██ 🢁🢁 ██  █ █  🢁🢁 ██
47//!    │ ▆ ██    ▁  🢁🢁 █▄ ██ ██ ██ ██ 🢁🢁 ██ ▄█ █▁ 🢁🢁 ██
48//!   1│⨯█ ██ ▂▁ ██ 🢁🢁 ██ ██ ██ ██ ██ 🢁🢁 ██ ██ ██ 🢁🢁 ██
49//!     0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
50//!  0: first         6: seventh      12: thirteenth
51//!  1: second        7: eighth       13: fourteenth
52//!  2: third         8: nineth       14: fifteenth
53//!  3: fourth        9: tenth        15: sixteenth
54//!  4: fifth        10: eleventh
55//!  5: sixth        11: twelfth
56//! ```
57
58#![warn(unused_lifetimes, missing_docs)]
59
60use colored::Colorize;
61
62/// Parameters for creating a `Chart`.
63pub mod params {
64
65    #[derive(Debug)]
66    #[allow(missing_docs)]
67    pub enum ViewPreference {
68        /// If any values smaller than `options.height * 8` exist in `data + compare.data`,
69        /// then display them. Larger values will be indicated by `🢁`.
70        /// If smaller values don't exist, then show the large ones.
71        Bottom,
72        /// If any values larger than `options.height * 8` exist in `data + compare.data`,
73        /// then display them. Smaller values will be indicated by `🢃`.
74        /// If larger values don't exist, then show the small ones.
75        Top,
76    }
77
78    #[derive(Debug)]
79    #[allow(missing_docs)]
80    pub enum DisplayMode<'a> {
81        /// Just the chart.
82        Compact,
83        /// Chart with labels at the bottom. One label is expected for each data point.
84        Portrait { labels: &'a [&'a str] },
85    }
86
87    #[derive(Debug)]
88    #[allow(missing_docs)]
89    pub struct ChartOptions<'a> {
90        /// The vertical size of the chart, in lines of text.
91        pub height: u16,
92        /// Determines how outliers are displayed.
93        pub view: ViewPreference,
94        /// Determines how space surrounding the chart is used.
95        pub display: DisplayMode<'a>,
96    }
97
98    impl<'a> Default for ChartOptions<'a> {
99        fn default() -> Self {
100            Self {
101                height: 8,
102                view: ViewPreference::Top,
103                display: DisplayMode::Compact,
104            }
105        }
106    }
107
108    #[derive(Debug)]
109    #[allow(missing_docs)]
110    pub struct ChartComparison<'a> {
111        /// Another slice of values to display next to `chart.data`.
112        pub data: &'a [u32],
113    }
114}
115
116use params::*;
117
118/// Display a slice of up to 100 `u32` values.
119pub struct Chart<'a> {
120    data: &'a [u32],
121    compare: Option<ChartComparison<'a>>,
122    options: ChartOptions<'a>,
123}
124
125impl<'a> Chart<'a> {
126    /// `data` and `compare.data` should have the same length.
127    pub fn new(
128        data: &'a [u32],
129        compare: Option<ChartComparison<'a>>,
130        options: ChartOptions<'a>,
131    ) -> Self {
132        assert!(
133            (1..=100).contains(&data.len()),
134            // supports charts up to 200 characters in width
135            "data should contain no more than 100 values"
136        );
137        if let Some(ref compare) = compare {
138            assert_eq!(
139                compare.data.len(),
140                data.len(),
141                "compare data length should equal primary data length",
142            )
143        }
144        if let DisplayMode::Portrait { labels } = options.display {
145            assert_eq!(
146                labels.len(),
147                data.len(),
148                "label count should equal data length",
149            );
150        }
151
152        Self {
153            data,
154            compare,
155            options,
156        }
157    }
158
159    fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
160        let (data_steps, cmp_data_steps) = self.scale_to_steps();
161        let steps_zipped: Vec<(&i16, Option<&i16>)> = match cmp_data_steps {
162            Some(ref cmp_data_steps) => data_steps
163                .iter()
164                .zip(cmp_data_steps.iter())
165                .map(|(a, b)| (a, Some(b)))
166                .collect(),
167            None => data_steps.iter().map(|a| (a, None)).collect(),
168        };
169
170        // determine the largest and smallest values displayed,
171        // for indicating the range of values next to the chart
172        let find_min_max = |data: &[u32], steps: &Vec<i16>| -> (u32, u32) {
173            let mut min = u32::MAX;
174            let mut max = u32::MIN;
175            for i in 0..data.len() {
176                if steps[i] > 0 {
177                    if data[i] < min {
178                        min = data[i];
179                    }
180                    if data[i] > max {
181                        max = data[i];
182                    }
183                }
184            }
185            (min, max)
186        };
187        let (mut min_visible, mut max_visible) = find_min_max(self.data, &data_steps);
188        if let (Some(c), Some(steps)) = (self.compare.as_ref(), &cmp_data_steps) {
189            let (min_visible_cmp, max_visible_cmp) = find_min_max(c.data, steps);
190            min_visible = std::cmp::min(min_visible, min_visible_cmp);
191            max_visible = std::cmp::max(max_visible, max_visible_cmp);
192        }
193
194        let get_print_char = |layer_num: u16, steps_count: i16| -> char {
195            // display heights are calculated in terms of "steps"
196            // a step is the height of this character: ▁
197            // each layer corresponds to a line of text, or 8 steps
198            let chars = " ▁▂▃▄▅▆▇█🢃🢁⨯".chars().collect::<Vec<_>>();
199
200            // determine the range of steps corresponding to this layer
201            // examples: (16,24] (8,16] (0,8]
202            let print_steps_start = (layer_num * 8) as i16;
203            let print_steps_end = ((layer_num + 1) * 8) as i16;
204
205            match steps_count {
206                0 if layer_num == 0 => chars[11],
207                -1 if layer_num == 0 => chars[9],
208                -2 => chars[10],
209                below if below <= print_steps_start => chars[0],
210                above if above > print_steps_end => chars[8],
211                value => chars[(value - print_steps_start) as usize],
212            }
213        };
214
215        // determine the character width to use for each bar based on
216        // how many characters are required to label it with a numeric offset
217        let bar_width_chars = if self.data.len() <= 10 { 1 } else { 2 };
218
219        let tick_spacer = max_visible
220            .to_string()
221            .chars()
222            .map(|_| " ")
223            .collect::<String>();
224
225        let mut write_layer = |layer_num: u16| -> std::fmt::Result {
226            // write left sidebar
227            if layer_num == self.options.height - 1 {
228                write!(f, "{max_visible}│")?;
229            } else if layer_num == 0 {
230                let gap = (0..tick_spacer.len() - min_visible.to_string().len())
231                    .map(|_| " ")
232                    .collect::<String>();
233                write!(f, "{gap}{min_visible}│")?;
234            } else {
235                write!(f, "{tick_spacer}│")?;
236            };
237
238            // write a layer of each bar
239            for (i, &(&pri_steps, cmp_steps)) in steps_zipped.iter().enumerate() {
240                let pri_char = get_print_char(layer_num, pri_steps).to_string();
241                match cmp_steps {
242                    None => {
243                        let pri_char = if i % 2 == 0 {
244                            pri_char.bright_white()
245                        } else {
246                            pri_char.white()
247                        };
248                        for _ in 0..bar_width_chars {
249                            write!(f, "{pri_char}")?;
250                        }
251                    }
252                    // if comparison, each bar only needs to be 1 character wide
253                    // for offsets to fit at the bottom
254                    Some(&cmp_steps) => {
255                        write!(f, "{}", pri_char.bright_white())?;
256
257                        let cmp_char = get_print_char(layer_num, cmp_steps).to_string();
258                        let pri_value = self.data[i];
259                        let cmp_value = self.compare.as_ref().unwrap().data[i];
260                        let cmp_char = if cmp_value <= pri_value {
261                            cmp_char.bright_green()
262                        } else {
263                            cmp_char.bright_red()
264                        };
265                        write!(f, "{cmp_char} ",)?;
266                    }
267                }
268            }
269
270            // move to layer below
271            writeln!(f)
272        };
273
274        // write layers
275        for layer_num in (0..self.options.height).rev() {
276            write_layer(layer_num)?;
277        }
278
279        // write offsets
280        write!(f, "{tick_spacer} ")?;
281        let mut chart_width = tick_spacer.len() as u16;
282        for i in 0..self.data.len() {
283            write!(f, "{i}")?;
284            let label_width = i.to_string().len() as u16;
285            chart_width += label_width;
286            let offset_width = match (self.compare.is_some(), bar_width_chars) {
287                (false, width) => width,
288                (true, _) => 3,
289            };
290            for _ in label_width..offset_width {
291                write!(f, " ")?;
292                chart_width += 1;
293            }
294        }
295
296        if let DisplayMode::Portrait { labels } = self.options.display {
297            writeln!(f)?;
298
299            // split labels into evenly-sized columns
300            // so as to fill horizontal space below the chart
301            // each label is allowed 12 characters before being truncated to fit
302            let col_count = std::cmp::max((chart_width as f32 / 17f32).floor() as usize, 1usize);
303            let col_length = labels.len().div_ceil(col_count);
304            let enumerated_labels = labels.iter().enumerate().collect::<Vec<_>>();
305            let label_cols = enumerated_labels.chunks(col_length).collect::<Vec<_>>();
306            let max_rows = label_cols.iter().map(|c| c.len()).max().unwrap();
307
308            for i in 0..max_rows {
309                for col in &label_cols {
310                    if let Some((offset, label)) = col.get(i) {
311                        write!(
312                            f,
313                            // each column requires 17 characters
314                            "{offset:>2}: {:<12} ",
315                            label.chars().take(12).collect::<String>()
316                        )?;
317                    }
318                }
319                writeln!(f)?;
320            }
321        } else {
322            writeln!(f)?;
323        }
324
325        Ok(())
326    }
327
328    fn scale_to_steps(&self) -> (Vec<i16>, Option<Vec<i16>>) {
329        // determine the largest possible measurement that can be expressed within
330        // `height` lines, in terms of steps.
331        let max_step_count: u16 = self.options.height * 8;
332
333        // determine the factor by which to scale all measurements,
334        // so that the largest one fills the available vertical space.
335        let all_measurements = self
336            .data
337            .iter()
338            .chain(self.compare.as_ref().map(|c| c.data).unwrap_or(&[]).iter())
339            .filter(|&&m| m > 0);
340        let all_max = all_measurements.clone().max().unwrap();
341        let unit_height_steps: u16 = std::cmp::max(
342            (max_step_count as f32 / *all_max as f32).floor() as u16,
343            1u16,
344        );
345
346        // determine which measurements can not be expressed in terms of steps
347        // without additional scaling
348        let (excessive, unexcessive) = all_measurements.clone().partition::<Vec<&u32>, _>(|&&m| {
349            m > u16::MAX as u32 || m as u16 * unit_height_steps > max_step_count
350        });
351        let low_max = unexcessive.iter().max();
352        let high_max = excessive.iter().max();
353
354        // additional scale factor
355        let (show_excessive, scale_factor) = match (&self.options.view, low_max, high_max) {
356            // fit the chart to the largest small value
357            (ViewPreference::Bottom, Some(&&low_max), _)
358            | (ViewPreference::Top, Some(&&low_max), None) => {
359                (false, max_step_count as f32 / low_max as f32)
360            }
361            // the fit the chart to the largest large value
362            (ViewPreference::Top, _, Some(&&high_max))
363            | (ViewPreference::Bottom, None, Some(&&high_max)) => {
364                (true, max_step_count as f32 / high_max as f32)
365            }
366            _ => unimplemented!(),
367        };
368
369        // convert measurements to step counts, expressed as signed integer
370        // to allow indication of too small (-1) and too large (-2)
371        let measurement_to_step_count = |m: &u32| -> i16 {
372            if *m == 0 {
373                return 0;
374            }
375            match (excessive.is_empty(), show_excessive, excessive.contains(&m)) {
376                // some are excessive and we don't want them and this is one of them
377                (false, false, true) => -2i16,
378                // some are excessive and we want them, but this isn't one of them
379                (false, true, false) => -1i16,
380                // otherwise, use the scale factor
381                _ => {
382                    let step_count = (*m as f32 * scale_factor) as i16;
383                    if step_count == 0 {
384                        // excessive measurement, but still invisible next to max
385                        -1i16
386                    } else {
387                        step_count
388                    }
389                }
390            }
391        };
392        let scaled_data: Vec<i16> = self.data.iter().map(measurement_to_step_count).collect();
393        let scaled_data_cmp: Option<Vec<i16>> = self
394            .compare
395            .as_ref()
396            .map(|c| c.data.iter().map(measurement_to_step_count).collect());
397
398        (scaled_data, scaled_data_cmp)
399    }
400}
401
402impl<'a> std::fmt::Display for Chart<'a> {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        self.render(f)
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    // cargo test -- --nocapture
411
412    use super::*;
413
414    #[test]
415    fn test_two_digit_width() {
416        let chart = Chart::new(
417            &[23, 32, 44, 0, 2, 44, 5, 23, 42, 29, 16],
418            None,
419            ChartOptions::default(),
420        );
421        println!("\ntwo_digit_width\n{chart}");
422    }
423
424    #[test]
425    fn test_value_too_small_for_top() {
426        let chart = Chart::new(
427            &[0, 6837, 18067, 352038],
428            None,
429            ChartOptions {
430                height: 5,
431                view: ViewPreference::Top,
432                display: DisplayMode::Compact,
433            },
434        );
435        println!("\nvalue_too_small_for_top\n{chart}");
436    }
437
438    #[test]
439    fn test_view_bottom_with_only_large() {
440        let chart = Chart::new(
441            &[2332, 3232, 3244, 0],
442            None,
443            ChartOptions {
444                height: 5,
445                view: ViewPreference::Bottom,
446                display: DisplayMode::Compact,
447            },
448        );
449        println!("\nview_bottom_with_only_large\n{chart}");
450    }
451
452    #[test]
453    fn test_view_top_with_only_small() {
454        let chart = Chart::new(
455            &[23, 32, 44, 0],
456            None,
457            ChartOptions {
458                height: 10,
459                view: ViewPreference::Top,
460                display: DisplayMode::Compact,
461            },
462        );
463        println!("\nview_top_with_only_small\n{chart}");
464    }
465
466    #[test]
467    fn test_comparison_portrait() {
468        let chart = Chart::new(
469            &[
470                0, 22, 2, 9, 223, 34, 33, 66, 76, 122, 199, 33, 12, 89, 1222, 100,
471            ],
472            Some(ChartComparison {
473                data: &[
474                    14, 20, 1, 8, 223, 12, 56, 79, 69, 100, 1122, 33, 45, 9, 9000, 78,
475                ],
476            }),
477            ChartOptions {
478                height: 16,
479                view: ViewPreference::Bottom,
480                display: DisplayMode::Portrait {
481                    labels: &[
482                        "first",
483                        "second",
484                        "third",
485                        "fourth",
486                        "fifth",
487                        "sixth",
488                        "seventh",
489                        "eighth",
490                        "nineth",
491                        "tenth",
492                        "eleventh",
493                        "twelfth",
494                        "thirteenth",
495                        "fourteenth",
496                        "fifteenth",
497                        "sixteenth",
498                    ],
499                },
500            },
501        );
502        println!("\ncomparison_portrait\n{chart}");
503    }
504}