Skip to main content

scope_cli/cli/
charts.rs

1//! The one chart component of the TUI and the monitor (#58).
2//!
3//! [`Sparkline`] draws a series as bars, with a gap where a value is
4//! missing and a line where its threshold is. [`HealthStrip`] draws one
5//! `✓` or `✗` per run. Both show the newest values that fit, at the right
6//! edge. Styles come from the caller (theme roles); nothing here names a
7//! color.
8
9use crate::cli::theme::{ERR, OK, SECONDARY};
10use ratatui::buffer::Buffer;
11use ratatui::layout::Rect;
12use ratatui::style::Style;
13use ratatui::widgets::Widget;
14use scope::market::series::Threshold;
15
16/// Bar glyphs from 1/8 to a full cell.
17const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
18
19/// Eighths of a cell, the vertical step of a bar.
20const STEPS_PER_ROW: u32 = 8;
21
22/// The glyph of a threshold line in the cells no bar fills.
23const THRESHOLD_GLYPH: &str = "─";
24
25/// A run that passed and one that failed, in a [`HealthStrip`].
26const PASS_GLYPH: &str = "✓";
27const FAIL_GLYPH: &str = "✗";
28
29/// A series as bars. `None` is a gap, never a zero.
30pub struct Sparkline<'a> {
31    values: &'a [Option<f64>],
32    threshold: Option<Threshold>,
33    style: Style,
34}
35
36impl<'a> Sparkline<'a> {
37    /// Bars of `values` (oldest first) in `style`.
38    pub fn new(values: &'a [Option<f64>], style: Style) -> Self {
39        Self {
40            values,
41            threshold: None,
42            style,
43        }
44    }
45
46    /// Adds the line of `threshold`, when there is one.
47    pub fn threshold(mut self, threshold: Option<Threshold>) -> Self {
48        self.threshold = threshold;
49        self
50    }
51}
52
53/// The value a threshold draws its line at.
54fn threshold_value(t: Threshold) -> f64 {
55    match t {
56        Threshold::Max(v) | Threshold::Min(v) | Threshold::Target(v) => v,
57    }
58}
59
60/// The newest `width` items of `items`.
61fn newest<T>(items: &[T], width: usize) -> &[T] {
62    &items[items.len().saturating_sub(width)..]
63}
64
65impl Widget for Sparkline<'_> {
66    fn render(self, area: Rect, buf: &mut Buffer) {
67        if area.is_empty() {
68            return;
69        }
70        let shown = newest(self.values, area.width as usize);
71        let line = self
72            .threshold
73            .map(threshold_value)
74            .filter(|v| v.is_finite());
75        let known = shown.iter().flatten().copied().filter(|v| v.is_finite());
76        let (mut lo, mut hi) = known.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
77            (lo.min(v), hi.max(v))
78        });
79        if let Some(t) = line {
80            // The scale includes the threshold, so its line is on the chart.
81            lo = lo.min(t);
82            hi = hi.max(t);
83        }
84        if !lo.is_finite() {
85            return; // no value to draw
86        }
87        let steps = u32::from(area.height) * STEPS_PER_ROW;
88        let range = hi - lo;
89        // A flat series draws half height: it is there, but not high or low.
90        let height_of = |v: f64| -> u32 {
91            if range <= 0.0 {
92                return steps / 2;
93            }
94            let h = ((v - lo) / range * f64::from(steps - 1)).round() as u32 + 1;
95            h.min(steps)
96        };
97        let x0 = area.right() - shown.len() as u16;
98        for (i, value) in shown.iter().enumerate() {
99            let x = x0 + i as u16;
100            let Some(v) = value.filter(|v| v.is_finite()) else {
101                continue; // a gap
102            };
103            let mut left = height_of(v);
104            for row in (area.top()..area.bottom()).rev() {
105                if left == 0 {
106                    break;
107                }
108                let eighths = left.min(STEPS_PER_ROW);
109                buf[(x, row)]
110                    .set_symbol(BARS[eighths as usize - 1])
111                    .set_style(self.style);
112                left -= eighths;
113            }
114        }
115        if let Some(t) = line {
116            let rows_up = height_of(t).saturating_sub(1) / STEPS_PER_ROW;
117            let row = area.bottom() - 1 - rows_up as u16;
118            for x in area.left()..area.right() {
119                let cell = &mut buf[(x, row)];
120                if cell.symbol() == " " {
121                    cell.set_symbol(THRESHOLD_GLYPH).set_style(SECONDARY);
122                }
123            }
124        }
125    }
126}
127
128/// One `✓` or `✗` per run, the newest at the right.
129pub struct HealthStrip<'a> {
130    runs: &'a [bool],
131}
132
133impl<'a> HealthStrip<'a> {
134    /// A strip of `runs` (true = healthy), oldest first.
135    pub fn new(runs: &'a [bool]) -> Self {
136        Self { runs }
137    }
138}
139
140impl Widget for HealthStrip<'_> {
141    fn render(self, area: Rect, buf: &mut Buffer) {
142        if area.is_empty() {
143            return;
144        }
145        let shown = newest(self.runs, area.width as usize);
146        let x0 = area.right() - shown.len() as u16;
147        for (i, healthy) in shown.iter().enumerate() {
148            let (glyph, style) = if *healthy {
149                (PASS_GLYPH, OK)
150            } else {
151                (FAIL_GLYPH, ERR)
152            };
153            buf[(x0 + i as u16, area.top())]
154                .set_symbol(glyph)
155                .set_style(style);
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn draw(widget: impl Widget, width: u16, height: u16) -> Vec<String> {
165        let area = Rect::new(0, 0, width, height);
166        let mut buf = Buffer::empty(area);
167        widget.render(area, &mut buf);
168        (0..height)
169            .map(|y| {
170                (0..width)
171                    .map(|x| buf[(x, y)].symbol().to_string())
172                    .collect()
173            })
174            .collect()
175    }
176
177    #[test]
178    fn test_a_missing_value_is_a_gap_not_a_zero() {
179        let values = [Some(1.0), None, Some(3.0)];
180        let rows = draw(Sparkline::new(&values, Style::new()), 3, 1);
181        let cells: Vec<char> = rows[0].chars().collect();
182        assert_eq!(cells[1], ' ', "{rows:?}");
183        assert_ne!(cells[0], ' ');
184        assert_eq!(cells[2], '█', "the top value fills its cell");
185    }
186
187    #[test]
188    fn test_only_the_newest_values_that_fit_are_drawn() {
189        // The first value is far above the rest. If it were drawn, the
190        // others would be short bars; cut off, the scale is theirs alone.
191        let values = [Some(100.0), Some(1.0), Some(2.0)];
192        let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
193        assert_eq!(rows[0], "▁█", "{rows:?}");
194    }
195
196    #[test]
197    fn test_the_threshold_line_fills_the_empty_cells_of_its_row() {
198        // Values 0 and 10 over 2 rows; a max of 8 sits in the top row.
199        let values = [Some(0.0), Some(10.0), Some(0.0)];
200        let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Max(8.0)));
201        let rows = draw(chart, 3, 2);
202        assert_eq!(rows[0], "─█─", "{rows:?}");
203        assert!(!rows[1].contains('─'), "{rows:?}");
204    }
205
206    #[test]
207    fn test_the_scale_includes_a_threshold_beyond_the_values() {
208        // A min depth of 100 with depths near 10: the line is at the top
209        // and the bars stay low, so the shortfall shows.
210        let values = [Some(10.0), Some(12.0)];
211        let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Min(100.0)));
212        let rows = draw(chart, 2, 2);
213        assert_eq!(rows[0], "──", "{rows:?}");
214        assert!(rows[1].chars().all(|c| c != '█'), "{rows:?}");
215    }
216
217    #[test]
218    fn test_no_values_draw_nothing() {
219        let values = [None, None];
220        let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
221        assert_eq!(rows[0], "  ");
222    }
223
224    #[test]
225    fn test_the_health_strip_marks_each_run_newest_last() {
226        let runs = [true, false, true, true];
227        let rows = draw(HealthStrip::new(&runs), 3, 1);
228        assert_eq!(rows[0], "✗✓✓");
229    }
230}