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::metrics::{Threshold, Unit};
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    track: bool,
35    min_span: Option<f64>,
36}
37
38impl<'a> Sparkline<'a> {
39    /// Bars of `values` (oldest first) in `style`.
40    pub fn new(values: &'a [Option<f64>], style: Style) -> Self {
41        Self {
42            values,
43            threshold: None,
44            style,
45            track: false,
46            min_span: None,
47        }
48    }
49
50    /// The smallest range the scale shows. A series that moves less than
51    /// `span` is drawn around the middle of the chart, so noise reads as a
52    /// flat line, not as full and empty bars.
53    pub fn min_span(mut self, span: f64) -> Self {
54        self.min_span = Some(span).filter(|s| s.is_finite() && *s > 0.0);
55        self
56    }
57
58    /// Track mode (#58 market view, J4): the bars fill a `─` track from the
59    /// left, one cell per run, and scroll when it is full. A bar outside the
60    /// threshold (above a `Max`, below a `Min`) is drawn in the error color;
61    /// no threshold line is drawn.
62    pub fn track(mut self) -> Self {
63        self.track = true;
64        self
65    }
66
67    /// Adds the line of `threshold`, when there is one.
68    pub fn threshold(mut self, threshold: Option<Threshold>) -> Self {
69        self.threshold = threshold;
70        self
71    }
72}
73
74/// The smallest range a chart of `unit` shows, so noise reads as a flat
75/// line: 10 bps of the price, 0.05 percentage points, 5% of an amount.
76pub(crate) fn min_span(unit: Unit, values: &[Option<f64>]) -> f64 {
77    let latest = values
78        .iter()
79        .rev()
80        .flatten()
81        .next()
82        .copied()
83        .unwrap_or(0.0)
84        .abs();
85    match unit {
86        Unit::Price | Unit::Native => (latest * 0.001).max(1e-9),
87        Unit::Percent => 0.05,
88        Unit::Quote | Unit::Usd => (latest * 0.05).max(1.0),
89        Unit::Count => 1.0,
90        Unit::Score => 0.5,
91        Unit::Flag => 1.0,
92    }
93}
94
95/// `! Window too small: enlarge it 12 columns wider and 8 rows taller to
96/// see {what}`: what a view says when it cannot show `what` in full.
97pub(crate) fn enlarge_hint(cols: u16, rows: u16, what: &str) -> String {
98    let n = |k: u16, one: &str, many: &str| format!("{k} {}", if k == 1 { one } else { many });
99    let by = match (cols, rows) {
100        (0, r) => format!("{} taller", n(r, "row", "rows")),
101        (c, 0) => format!("{} wider", n(c, "column", "columns")),
102        (c, r) => format!(
103            "{} wider and {} taller",
104            n(c, "column", "columns"),
105            n(r, "row", "rows")
106        ),
107    };
108    format!("! Window too small: enlarge it {by} to see {what}")
109}
110
111/// The value a threshold draws its line at.
112fn threshold_value(t: Threshold) -> f64 {
113    match t {
114        Threshold::Max(v) | Threshold::Min(v) | Threshold::Target(v) => v,
115    }
116}
117
118/// The newest `width` items of `items`.
119fn newest<T>(items: &[T], width: usize) -> &[T] {
120    &items[items.len().saturating_sub(width)..]
121}
122
123impl Widget for Sparkline<'_> {
124    fn render(self, area: Rect, buf: &mut Buffer) {
125        if area.is_empty() {
126            return;
127        }
128        let shown = newest(self.values, area.width as usize);
129        let line = self
130            .threshold
131            .map(threshold_value)
132            .filter(|v| v.is_finite());
133        let known = shown.iter().flatten().copied().filter(|v| v.is_finite());
134        let (mut lo, mut hi) = known.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
135            (lo.min(v), hi.max(v))
136        });
137        if let Some(t) = line.filter(|_| !self.track) {
138            // The scale includes the threshold, so its line is on the chart.
139            lo = lo.min(t);
140            hi = hi.max(t);
141        }
142        if !lo.is_finite() {
143            return; // no value to draw
144        }
145        if let Some(span) = self.min_span
146            && hi - lo < span
147        {
148            let mid = (lo + hi) / 2.0;
149            lo = mid - span / 2.0;
150            hi = mid + span / 2.0;
151        }
152        let steps = u32::from(area.height) * STEPS_PER_ROW;
153        let range = hi - lo;
154        // A flat series draws half height: it is there, but not high or low.
155        let height_of = |v: f64| -> u32 {
156            if range <= 0.0 {
157                return steps / 2;
158            }
159            let h = ((v - lo) / range * f64::from(steps - 1)).round() as u32 + 1;
160            h.min(steps)
161        };
162        let x0 = if self.track {
163            area.left()
164        } else {
165            area.right() - shown.len() as u16
166        };
167        if self.track {
168            // The empty track: what the coming runs will fill.
169            let row = area.bottom() - 1;
170            for x in x0 + shown.len() as u16..area.right() {
171                buf[(x, row)]
172                    .set_symbol(THRESHOLD_GLYPH)
173                    .set_style(SECONDARY);
174            }
175        }
176        for (i, value) in shown.iter().enumerate() {
177            let x = x0 + i as u16;
178            let Some(v) = value.filter(|v| v.is_finite()) else {
179                continue; // a gap
180            };
181            let style = match (self.track, self.threshold) {
182                (true, Some(Threshold::Max(t))) if v > t => ERR,
183                (true, Some(Threshold::Min(t))) if v < t => ERR,
184                _ => self.style,
185            };
186            let mut left = height_of(v);
187            for row in (area.top()..area.bottom()).rev() {
188                if left == 0 {
189                    break;
190                }
191                let eighths = left.min(STEPS_PER_ROW);
192                buf[(x, row)]
193                    .set_symbol(BARS[eighths as usize - 1])
194                    .set_style(style);
195                left -= eighths;
196            }
197        }
198        if let Some(t) = line.filter(|_| !self.track) {
199            let rows_up = height_of(t).saturating_sub(1) / STEPS_PER_ROW;
200            let row = area.bottom() - 1 - rows_up as u16;
201            for x in area.left()..area.right() {
202                let cell = &mut buf[(x, row)];
203                if cell.symbol() == " " {
204                    cell.set_symbol(THRESHOLD_GLYPH).set_style(SECONDARY);
205                }
206            }
207        }
208    }
209}
210
211/// One `✓` or `✗` per run, the newest at the right.
212pub struct HealthStrip<'a> {
213    runs: &'a [bool],
214    track: bool,
215}
216
217impl<'a> HealthStrip<'a> {
218    /// A strip of `runs` (true = healthy), oldest first.
219    pub fn new(runs: &'a [bool]) -> Self {
220        Self { runs, track: false }
221    }
222
223    /// Track mode: from the left over a `─` track, as [`Sparkline::track`].
224    pub fn track(mut self) -> Self {
225        self.track = true;
226        self
227    }
228}
229
230impl Widget for HealthStrip<'_> {
231    fn render(self, area: Rect, buf: &mut Buffer) {
232        if area.is_empty() {
233            return;
234        }
235        let shown = newest(self.runs, area.width as usize);
236        let x0 = if self.track {
237            for x in area.left() + shown.len() as u16..area.right() {
238                buf[(x, area.top())]
239                    .set_symbol(THRESHOLD_GLYPH)
240                    .set_style(SECONDARY);
241            }
242            area.left()
243        } else {
244            area.right() - shown.len() as u16
245        };
246        for (i, healthy) in shown.iter().enumerate() {
247            let (glyph, style) = if *healthy {
248                (PASS_GLYPH, OK)
249            } else {
250                (FAIL_GLYPH, ERR)
251            };
252            buf[(x0 + i as u16, area.top())]
253                .set_symbol(glyph)
254                .set_style(style);
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn draw(widget: impl Widget, width: u16, height: u16) -> Vec<String> {
264        let area = Rect::new(0, 0, width, height);
265        let mut buf = Buffer::empty(area);
266        widget.render(area, &mut buf);
267        (0..height)
268            .map(|y| {
269                (0..width)
270                    .map(|x| buf[(x, y)].symbol().to_string())
271                    .collect()
272            })
273            .collect()
274    }
275
276    #[test]
277    fn test_track_fills_from_the_left_then_scrolls() {
278        let values = [Some(1.0), Some(2.0)];
279        let rows = draw(Sparkline::new(&values, Style::new()).track(), 6, 1);
280        assert_eq!(rows[0], "▁█────", "{rows:?}");
281        let values: Vec<Option<f64>> = (1..=8).map(|v| Some(f64::from(v))).collect();
282        let rows = draw(Sparkline::new(&values, Style::new()).track(), 4, 1);
283        assert!(!rows[0].contains('─'), "a full track scrolls: {rows:?}");
284        assert!(
285            rows[0].ends_with('█'),
286            "the newest at the right edge: {rows:?}"
287        );
288    }
289
290    #[test]
291    fn test_track_colors_a_bar_outside_its_limit_and_draws_no_threshold_line() {
292        let values = [Some(1.0), Some(5.0)];
293        let area = Rect::new(0, 0, 4, 2);
294        let mut buf = Buffer::empty(area);
295        Sparkline::new(&values, OK)
296            .threshold(Some(Threshold::Max(3.0)))
297            .track()
298            .render(area, &mut buf);
299        assert_eq!(buf[(0, 1)].style().fg, OK.fg, "within the limit");
300        assert_eq!(buf[(1, 1)].style().fg, ERR.fg, "above the max");
301        // The only `─` cells are the empty track on the bottom row.
302        assert_eq!(buf[(2, 0)].symbol(), " ");
303        assert_eq!(buf[(2, 1)].symbol(), "─");
304    }
305
306    #[test]
307    fn test_noise_under_the_min_span_reads_flat() {
308        // Depth moving 0.1% must not draw full and empty bars.
309        let values = [Some(1000.0), Some(1001.0), Some(1000.5)];
310        let rows = draw(Sparkline::new(&values, Style::new()).min_span(100.0), 3, 1);
311        let heights: Vec<usize> = rows[0]
312            .chars()
313            .map(|c| BARS.iter().position(|b| b.starts_with(c)).unwrap())
314            .collect();
315        assert!(
316            heights.iter().all(|h| (3..=4).contains(h)),
317            "near the middle: {rows:?}"
318        );
319        // A real move larger than the span still fills the scale.
320        let values = [Some(1000.0), Some(1500.0)];
321        let rows = draw(Sparkline::new(&values, Style::new()).min_span(100.0), 2, 1);
322        assert_eq!(rows[0], "▁█", "{rows:?}");
323    }
324
325    #[test]
326    fn test_health_strip_track_fills_from_the_left() {
327        let rows = draw(HealthStrip::new(&[true, false]).track(), 5, 1);
328        assert_eq!(rows[0], "✓✗───");
329    }
330
331    #[test]
332    fn test_a_missing_value_is_a_gap_not_a_zero() {
333        let values = [Some(1.0), None, Some(3.0)];
334        let rows = draw(Sparkline::new(&values, Style::new()), 3, 1);
335        let cells: Vec<char> = rows[0].chars().collect();
336        assert_eq!(cells[1], ' ', "{rows:?}");
337        assert_ne!(cells[0], ' ');
338        assert_eq!(cells[2], '█', "the top value fills its cell");
339    }
340
341    #[test]
342    fn test_only_the_newest_values_that_fit_are_drawn() {
343        // The first value is far above the rest. If it were drawn, the
344        // others would be short bars; cut off, the scale is theirs alone.
345        let values = [Some(100.0), Some(1.0), Some(2.0)];
346        let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
347        assert_eq!(rows[0], "▁█", "{rows:?}");
348    }
349
350    #[test]
351    fn test_the_threshold_line_fills_the_empty_cells_of_its_row() {
352        // Values 0 and 10 over 2 rows; a max of 8 sits in the top row.
353        let values = [Some(0.0), Some(10.0), Some(0.0)];
354        let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Max(8.0)));
355        let rows = draw(chart, 3, 2);
356        assert_eq!(rows[0], "─█─", "{rows:?}");
357        assert!(!rows[1].contains('─'), "{rows:?}");
358    }
359
360    #[test]
361    fn test_the_scale_includes_a_threshold_beyond_the_values() {
362        // A min depth of 100 with depths near 10: the line is at the top
363        // and the bars stay low, so the shortfall shows.
364        let values = [Some(10.0), Some(12.0)];
365        let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Min(100.0)));
366        let rows = draw(chart, 2, 2);
367        assert_eq!(rows[0], "──", "{rows:?}");
368        assert!(rows[1].chars().all(|c| c != '█'), "{rows:?}");
369    }
370
371    #[test]
372    fn test_no_values_draw_nothing() {
373        let values = [None, None];
374        let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
375        assert_eq!(rows[0], "  ");
376    }
377
378    #[test]
379    fn test_the_health_strip_marks_each_run_newest_last() {
380        let runs = [true, false, true, true];
381        let rows = draw(HealthStrip::new(&runs), 3, 1);
382        assert_eq!(rows[0], "✗✓✓");
383    }
384}