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 ChartOptions {
161            height: height_lines,
162            display,
163            ..
164        } = &self.options;
165
166        // determine the character width to use for each bar based on
167        // how many characters are required to label it with a numeric offset
168        let bar_width_chars = if self.data.len() <= 10 { 1 } else { 2 };
169
170        // display heights are calculated in terms of "steps"
171        // a step is the height of this character: ▁ (8 per line)
172        let chars = " ▁▂▃▄▅▆▇█🢃🢁⨯".chars().collect::<Vec<_>>();
173
174        let (data_steps, cmp_data_steps) = self.scale_to_steps();
175        let steps_zipped: Vec<(&i16, Option<&i16>)> = match cmp_data_steps {
176            Some(ref cmp_data_steps) => data_steps
177                .iter()
178                .zip(cmp_data_steps.iter())
179                .map(|(a, b)| (a, Some(b)))
180                .collect(),
181            None => data_steps.iter().map(|a| (a, None)).collect(),
182        };
183
184        // determine the largest and smallest values displayed,
185        // for indicating the range of values next to the chart
186        let find_min_max = |data: &[u32], steps: &Vec<i16>| -> (u32, u32) {
187            let mut min = u32::MAX;
188            let mut max = u32::MIN;
189            for i in 0..data.len() {
190                if steps[i] > 0 {
191                    if data[i] < min {
192                        min = data[i];
193                    }
194                    if data[i] > max {
195                        max = data[i];
196                    }
197                }
198            }
199            (min, max)
200        };
201        let (mut min_visible, mut max_visible) = find_min_max(self.data, &data_steps);
202        if let (Some(c), Some(steps)) = (self.compare.as_ref(), &cmp_data_steps) {
203            let (min_visible_cmp, max_visible_cmp) = find_min_max(c.data, steps);
204            min_visible = std::cmp::min(min_visible, min_visible_cmp);
205            max_visible = std::cmp::max(max_visible, max_visible_cmp);
206        }
207
208        let tick_spacer = max_visible
209            .to_string()
210            .chars()
211            .map(|_| " ")
212            .collect::<String>();
213
214        // print data_steps in layers, from top to bottom.
215        // each layer corresponds to a line of text, or 8 steps
216        for layer_num in (0..*height_lines).rev() {
217            if layer_num == height_lines - 1 {
218                write!(f, "{max_visible}│")?;
219            } else if layer_num == 0 {
220                let gap = (0..tick_spacer.len() - min_visible.to_string().len())
221                    .map(|_| " ")
222                    .collect::<String>();
223                write!(f, "{gap}{min_visible}│")?;
224            } else {
225                write!(f, "{tick_spacer}│")?;
226            };
227
228            // determine the range of steps corresponding to this layer
229            // examples: (16,24] (8,16] (0,8]
230            let print_steps_start = (layer_num * 8) as i16;
231            let print_steps_end = ((layer_num + 1) * 8) as i16;
232
233            // print a layer of each bar, from left to right
234            let to_print_char = |steps_count: i16| -> char {
235                match steps_count {
236                    0 if layer_num == 0 => chars[11],
237                    -1 if layer_num == 0 => chars[9],
238                    -2 => chars[10],
239                    below if below <= print_steps_start => chars[0],
240                    above if above > print_steps_end => chars[8],
241                    value => chars[(value - print_steps_start) as usize],
242                }
243            };
244            for (i, &(&pri_steps, cmp_steps)) in steps_zipped.iter().enumerate() {
245                match cmp_steps {
246                    None => {
247                        let pri_char = if i % 2 == 0 {
248                            to_print_char(pri_steps).to_string().bright_white()
249                        } else {
250                            to_print_char(pri_steps).to_string().white()
251                        };
252                        for _ in 0..bar_width_chars {
253                            write!(f, "{pri_char}")?;
254                        }
255                    }
256                    // if comparison, each bar only needs to be 1 character wide
257                    // for offsets to fit at the bottom
258                    Some(&cmp_steps) => {
259                        write!(f, "{}", to_print_char(pri_steps).to_string().bright_white())
260                            .unwrap();
261
262                        let pri_value = self.data[i];
263                        let cmp_value = self.compare.as_ref().unwrap().data[i];
264                        let cmp_char = if cmp_value <= pri_value {
265                            to_print_char(cmp_steps).to_string().bright_green()
266                        } else {
267                            to_print_char(cmp_steps).to_string().bright_red()
268                        };
269                        write!(f, "{cmp_char} ",).unwrap();
270                    }
271                }
272            }
273
274            // move to layer below
275            writeln!(f)?;
276        }
277
278        // print offsets
279        write!(f, "{tick_spacer} ")?;
280        let mut chart_width = tick_spacer.len() as u16;
281        for i in 0..self.data.len() {
282            write!(f, "{i}")?;
283            let label_width = i.to_string().len() as u16;
284            chart_width += label_width;
285            let offset_width = match (self.compare.is_some(), bar_width_chars) {
286                (false, width) => width,
287                (true, _) => 3,
288            };
289            for _ in label_width..offset_width {
290                write!(f, " ")?;
291                chart_width += 1;
292            }
293        }
294
295        if let DisplayMode::Portrait { labels } = display {
296            writeln!(f)?;
297
298            // split labels into evenly-sized columns
299            // so as to fill horizontal space below the chart
300            // each label is allowed 12 characters before being truncated to fit
301            let col_count = std::cmp::max((chart_width as f32 / 17f32).floor() as usize, 1usize);
302            let col_length = labels.len().div_ceil(col_count);
303            let enumerated_labels = labels.iter().enumerate().collect::<Vec<_>>();
304            let label_cols = enumerated_labels.chunks(col_length).collect::<Vec<_>>();
305            let max_rows = label_cols.iter().map(|c| c.len()).max().unwrap();
306
307            for i in 0..max_rows {
308                for col in &label_cols {
309                    if let Some((offset, label)) = col.get(i) {
310                        write!(
311                            f,
312                            // each column requires 17 characters
313                            "{offset:>2}: {:<12} ",
314                            label.chars().take(12).collect::<String>()
315                        )?;
316                    }
317                }
318                writeln!(f)?;
319            }
320        } else {
321            writeln!(f)?;
322        }
323
324        Ok(())
325    }
326
327    fn scale_to_steps(&self) -> (Vec<i16>, Option<Vec<i16>>) {
328        // determine the largest possible measurement that can be expressed within
329        // `height` lines, in terms of steps.
330        let max_step_count: u16 = self.options.height * 8;
331
332        // determine the factor by which to scale all measurements,
333        // so that the largest one fills the available vertical space.
334        let all_measurements = self
335            .data
336            .iter()
337            .chain(self.compare.as_ref().map(|c| c.data).unwrap_or(&[]).iter())
338            .filter(|&&m| m > 0);
339        let all_max = all_measurements.clone().max().unwrap();
340        let unit_height_steps: u16 = std::cmp::max(
341            (max_step_count as f32 / *all_max as f32).floor() as u16,
342            1u16,
343        );
344
345        // determine which measurements can not be expressed in terms of steps
346        // without additional scaling
347        let (excessive, unexcessive) = all_measurements.clone().partition::<Vec<&u32>, _>(|&&m| {
348            m > u16::MAX as u32 || m as u16 * unit_height_steps > max_step_count
349        });
350        let low_max = unexcessive.iter().max();
351        let high_max = excessive.iter().max();
352
353        // additional scale factor
354        let (show_excessive, scale_factor) = match (&self.options.view, low_max, high_max) {
355            // fit the chart to the largest small value
356            (ViewPreference::Bottom, Some(&&low_max), _)
357            | (ViewPreference::Top, Some(&&low_max), None) => {
358                (false, max_step_count as f32 / low_max as f32)
359            }
360            // the fit the chart to the largest large value
361            (ViewPreference::Top, _, Some(&&high_max))
362            | (ViewPreference::Bottom, _, Some(&&high_max)) => {
363                (true, max_step_count as f32 / high_max as f32)
364            }
365            _ => unimplemented!(),
366        };
367
368        // convert measurements to step counts, expressed as signed integer
369        // to allow indication of too small (-1) and too large (-2)
370        let measurement_to_step_count = |m: &u32| -> i16 {
371            if *m == 0 {
372                return 0;
373            }
374            match (excessive.is_empty(), show_excessive, excessive.contains(&m)) {
375                // some are excessive and we don't want them and this is one of them
376                (false, false, true) => -2i16,
377                // some are excessive and we want them, but this isn't one of them
378                (false, true, false) => -1i16,
379                // otherwise, use the scale factor
380                _ => {
381                    let step_count = (*m as f32 * scale_factor) as i16;
382                    if step_count == 0 {
383                        // excessive measurement, but still invisible next to max
384                        -1i16
385                    } else {
386                        step_count
387                    }
388                }
389            }
390        };
391        let scaled_data: Vec<i16> = self.data.iter().map(measurement_to_step_count).collect();
392        let scaled_data_cmp: Option<Vec<i16>> = self
393            .compare
394            .as_ref()
395            .map(|c| c.data.iter().map(measurement_to_step_count).collect());
396
397        (scaled_data, scaled_data_cmp)
398    }
399}
400
401impl<'a> std::fmt::Display for Chart<'a> {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        self.render(f)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    // cargo test -- --nocapture
410
411    use super::*;
412
413    #[test]
414    fn test_two_digit_width() {
415        let chart = Chart::new(
416            &[23, 32, 44, 0, 2, 44, 5, 23, 42, 29, 16],
417            None,
418            ChartOptions::default(),
419        );
420        println!("\ntwo_digit_width\n{chart}");
421    }
422
423    #[test]
424    fn test_excessive_value_too_small_for_height() {
425        let chart = Chart::new(
426            &[0, 6837, 18067, 352038],
427            None,
428            ChartOptions {
429                height: 5,
430                view: ViewPreference::Top,
431                display: DisplayMode::Compact,
432            },
433        );
434        println!("\nexcessive_value_too_small_for_height\n{chart}");
435    }
436
437    #[test]
438    fn test_prefer_small_but_only_large() {
439        let chart = Chart::new(
440            &[2332, 3232, 3244, 0],
441            None,
442            ChartOptions {
443                height: 5,
444                view: ViewPreference::Bottom,
445                display: DisplayMode::Compact,
446            },
447        );
448        println!("\nprefer_small_but_large\n{chart}");
449    }
450
451    #[test]
452    fn test_prefer_large_but_only_small() {
453        let chart = Chart::new(
454            &[23, 32, 44, 0],
455            None,
456            ChartOptions {
457                height: 10,
458                view: ViewPreference::Top,
459                display: DisplayMode::Compact,
460            },
461        );
462        println!("\nprefer_large_but_small\n{chart}");
463    }
464
465    #[test]
466    fn test_comparison_portrait() {
467        let chart = Chart::new(
468            &[
469                0, 22, 2, 9, 223, 34, 33, 66, 76, 122, 199, 33, 12, 89, 1222, 100,
470            ],
471            Some(ChartComparison {
472                data: &[
473                    14, 20, 1, 8, 223, 12, 56, 79, 69, 100, 1122, 33, 45, 9, 9000, 78,
474                ],
475            }),
476            ChartOptions {
477                height: 16,
478                view: ViewPreference::Bottom,
479                display: DisplayMode::Portrait {
480                    labels: &[
481                        "first",
482                        "second",
483                        "third",
484                        "fourth",
485                        "fifth",
486                        "sixth",
487                        "seventh",
488                        "eighth",
489                        "nineth",
490                        "tenth",
491                        "eleventh",
492                        "twelfth",
493                        "thirteenth",
494                        "fourteenth",
495                        "fifteenth",
496                        "sixteenth",
497                    ],
498                },
499            },
500        );
501        println!("\ncomparison_portrait\n{chart}");
502    }
503}