Skip to main content

datui_lib/widgets/
crosshair.rs

1//! The XY chart's crosshair: a cursor that stands on a point's x, steps to the next
2//! point a column or more away, and a readout under the plot of every series' value
3//! there. Pure: the chart view draws it, the chart keys and a click move it.
4
5use ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::Style,
9    text::{Line, Span},
10};
11use unicode_width::UnicodeWidthStr;
12
13use crate::chart_data::{AxisNumbers, XAxisTemporalKind, x_datetime, x_time};
14use crate::glyphs::Glyphs;
15use crate::widgets::axes::{Track, cut};
16
17/// Cells between two entries of the readout.
18const READOUT_GAP: usize = 3;
19
20/// Where an XY plot was drawn: its cells and the x range across them, as the series'
21/// marker divides each cell.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct PlotPlace {
24    pub graph: Rect,
25    pub x_bounds: [f64; 2],
26    /// Dots across a cell.
27    pub sub: u16,
28}
29
30impl PlotPlace {
31    /// The column `x` is drawn in, as the series' points are.
32    pub fn column(&self, x: f64) -> u16 {
33        let [lo, hi] = self.x_bounds;
34        let f = if hi > lo { (x - lo) / (hi - lo) } else { 0.0 };
35        Track {
36            start: self.graph.left(),
37            cells: self.graph.width,
38            sub: self.sub,
39        }
40        .cell(f)
41    }
42}
43
44/// A move of the cursor.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum Move {
47    Left,
48    Right,
49    First,
50    Last,
51}
52
53/// The x of every point of every series, in order, each once.
54pub fn xs(series: &[Vec<(f64, f64)>]) -> Vec<f64> {
55    let mut xs: Vec<f64> = series
56        .iter()
57        .flatten()
58        .map(|&(x, _)| x)
59        .filter(|x| x.is_finite())
60        .collect();
61    xs.sort_by(f64::total_cmp);
62    xs.dedup();
63    xs
64}
65
66/// The x among `xs` nearest `x`.
67pub fn nearest(xs: &[f64], x: f64) -> Option<f64> {
68    let i = xs.partition_point(|&v| v < x);
69    [i.checked_sub(1), Some(i)]
70        .into_iter()
71        .flatten()
72        .filter_map(|i| xs.get(i).copied())
73        .min_by(|a, b| (a - x).abs().total_cmp(&(b - x).abs()))
74}
75
76/// Where the cursor at `from` goes: to the first point of the next column that has
77/// one, so sparse points are stepped one by one and dense ones a column at a time;
78/// or to the first or last point.
79pub fn step(xs: &[f64], place: &PlotPlace, from: f64, to: Move) -> Option<f64> {
80    let here = place.column(from);
81    match to {
82        Move::First => xs.first().copied(),
83        Move::Last => xs.last().copied(),
84        Move::Right => {
85            let i = xs.partition_point(|&v| place.column(v) <= here);
86            xs.get(i).copied()
87        }
88        Move::Left => {
89            let i = xs.partition_point(|&v| place.column(v) < here);
90            // The first point of that column, so stepping back and forth lands alike.
91            i.checked_sub(1).and_then(|prev| {
92                let column = place.column(xs[prev]);
93                let first = xs.partition_point(|&v| place.column(v) < column);
94                xs.get(first).copied()
95            })
96        }
97    }
98    .or_else(|| nearest(xs, from))
99}
100
101/// The point drawn nearest `column`: a click lands on what it was aimed at.
102pub fn at_column(xs: &[f64], place: &PlotPlace, column: u16) -> Option<f64> {
103    let i = xs.partition_point(|&v| place.column(v) < column);
104    [i.checked_sub(1), Some(i)]
105        .into_iter()
106        .flatten()
107        .filter_map(|i| xs.get(i).copied())
108        .min_by_key(|&v| place.column(v).abs_diff(column))
109}
110
111/// `x` as the readout writes it: in full, a date or time to the second, a number as
112/// the table writes its column.
113pub fn format_x(x: f64, kind: XAxisTemporalKind, numbers: &AxisNumbers) -> String {
114    let when = match kind {
115        XAxisTemporalKind::Numeric => None,
116        XAxisTemporalKind::Date => x_datetime(x, kind).map(|d| d.format("%Y-%m-%d").to_string()),
117        XAxisTemporalKind::Time => x_time(x).map(|t| t.format("%H:%M:%S%.f").to_string()),
118        _ => x_datetime(x, kind).map(|d| d.format("%Y-%m-%d %H:%M:%S%.f").to_string()),
119    };
120    when.unwrap_or_else(|| format_number(x, numbers))
121}
122
123/// `v` as the table writes its column: a whole number plainly, a fraction to the
124/// format's precision or, without one, as Polars writes it (`19.434783`), never every
125/// digit an aggregate's division left.
126pub fn format_number(v: f64, numbers: &AxisNumbers) -> String {
127    let mut out = String::new();
128    let format = &numbers.format;
129    if v.fract() == 0.0 || format.float_precision.is_some() || !v.is_finite() {
130        format.write_f64(v, &mut String::new(), &mut out);
131    } else {
132        format.regroup_decimal(&polars::prelude::AnyValue::Float64(v).str_value(), &mut out);
133    }
134    out
135}
136
137/// Each series' value at `x`, by its points before any log: `None` where it has no
138/// point there, a gap in its line.
139pub fn values_at(series: &[Vec<(f64, f64)>], x: f64) -> Vec<Option<f64>> {
140    series
141        .iter()
142        .map(|points| points.iter().find(|&&(px, _)| px == x).map(|&(_, y)| y))
143        .collect()
144}
145
146/// One entry of the readout: a name in its color and the value beside it.
147pub struct Entry {
148    pub name: String,
149    pub name_style: Style,
150    pub value: String,
151    pub value_style: Style,
152}
153
154impl Entry {
155    fn width(&self) -> usize {
156        self.name.width() + 2 + self.value.width()
157    }
158}
159
160/// The readout's lines in `width` cells: entries flow left to right, three cells
161/// apart, onto as many lines as they take; an entry wider than a line is cut.
162pub fn readout_lines(entries: &[Entry], width: usize, g: &Glyphs) -> Vec<Line<'static>> {
163    let mut lines: Vec<Line<'static>> = Vec::new();
164    let mut used = 0;
165    for entry in entries {
166        let w = entry.width();
167        if lines.is_empty() || used + READOUT_GAP + w > width {
168            lines.push(Line::default());
169            used = 0;
170        } else {
171            if let Some(line) = lines.last_mut() {
172                line.push_span(Span::raw(" ".repeat(READOUT_GAP)));
173            }
174            used += READOUT_GAP;
175        }
176        let line = lines.last_mut().expect("a line was pushed");
177        let name = cut(&format!("{}:", entry.name), width, g);
178        let room = width.saturating_sub(name.width() + 1);
179        used += name.width();
180        line.push_span(Span::styled(name, entry.name_style));
181        if room > 0 {
182            let value = cut(&entry.value, room, g);
183            used += 1 + value.width();
184            line.push_span(Span::raw(" "));
185            line.push_span(Span::styled(value, entry.value_style));
186        }
187    }
188    lines
189}
190
191/// The cursor: a line down the plot at `x`'s column, under the series, whose marks
192/// stay where they fall; and its tick on the x axis.
193pub fn draw(buf: &mut Buffer, place: &PlotPlace, x: f64, style: Style, g: &Glyphs) {
194    let graph = place.graph;
195    if graph.is_empty() {
196        return;
197    }
198    let column = place.column(x);
199    for y in graph.top()..graph.bottom() {
200        let cell = &mut buf[(column, y)];
201        let symbol = cell.symbol();
202        if matches!(symbol, " " | "\u{2800}")
203            || symbol == g.plot.grid_across
204            || symbol == g.plot.grid_down
205        {
206            cell.set_symbol(g.plot.axis.vertical).set_style(style);
207        }
208    }
209    let axis = graph.bottom();
210    if axis < buf.area.bottom() {
211        let cell = &mut buf[(column, axis)];
212        if cell.symbol() == g.plot.axis.horizontal || cell.symbol() == g.plot.tick_x {
213            cell.set_symbol(g.plot.tick_x).set_style(style);
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// A mean reads as the table writes a float, not to the last digit, with the
223    /// table's grouping; a whole number reads plainly.
224    #[test]
225    fn readout_numbers_read_as_the_table_writes_them() {
226        let plain = AxisNumbers::default();
227        assert_eq!(format_number(19.434782608695652, &plain), "19.434783");
228        assert_eq!(format_number(-0.5, &plain), "-0.5");
229        let grouped = AxisNumbers {
230            format: crate::numfmt::NumberFormat::preset("thousands").unwrap(),
231            whole: false,
232        };
233        assert_eq!(format_number(12345.678901234, &grouped), "12,345.678901");
234        let whole = AxisNumbers {
235            whole: true,
236            ..grouped
237        };
238        assert_eq!(format_number(1234.0, &whole), "1,234");
239    }
240
241    fn place(width: u16) -> PlotPlace {
242        PlotPlace {
243            graph: Rect::new(10, 0, width, 10),
244            x_bounds: [0.0, 100.0],
245            sub: 1,
246        }
247    }
248
249    /// Sparse points step one by one; dense ones a column at a time, never past a
250    /// column that has a point.
251    #[test]
252    fn the_cursor_steps_point_to_point_and_column_to_column() {
253        let sparse = xs(&[vec![(0.0, 1.0), (50.0, 2.0), (100.0, 3.0)]]);
254        let p = place(101);
255        assert_eq!(step(&sparse, &p, 0.0, Move::Right), Some(50.0));
256        assert_eq!(step(&sparse, &p, 50.0, Move::Right), Some(100.0));
257        assert_eq!(step(&sparse, &p, 100.0, Move::Right), Some(100.0), "stays");
258        assert_eq!(step(&sparse, &p, 50.0, Move::Left), Some(0.0));
259        assert_eq!(step(&sparse, &p, 0.0, Move::Left), Some(0.0), "stays");
260
261        // A thousand points across eleven columns: ten-odd a column.
262        let dense: Vec<f64> = (0..1000).map(|i| f64::from(i) / 10.0).collect();
263        let p = place(11);
264        let mut at = 0.0;
265        let mut columns = vec![p.column(at)];
266        while let Some(next) = step(&dense, &p, at, Move::Right).filter(|&n| n != at) {
267            at = next;
268            columns.push(p.column(at));
269        }
270        assert_eq!(columns, (10..21).collect::<Vec<u16>>());
271        // And back the same way, on the first point of each column.
272        let back = step(&dense, &p, at, Move::Left).unwrap();
273        assert_eq!(p.column(back), 19);
274        assert_eq!(step(&dense, &p, back, Move::Right), Some(at));
275        assert_eq!(step(&dense, &p, 42.0, Move::First), Some(0.0));
276        assert_eq!(step(&dense, &p, 42.0, Move::Last), Some(99.9));
277    }
278
279    /// A click lands on the point drawn nearest it.
280    #[test]
281    fn a_click_lands_on_the_nearest_point() {
282        let sparse = xs(&[vec![(0.0, 1.0), (50.0, 2.0)], vec![(100.0, 3.0)]]);
283        let p = place(101);
284        assert_eq!(at_column(&sparse, &p, 10), Some(0.0));
285        assert_eq!(at_column(&sparse, &p, 40), Some(50.0));
286        assert_eq!(at_column(&sparse, &p, 300), Some(100.0));
287        assert_eq!(nearest(&sparse, 80.0), Some(100.0));
288        assert_eq!(nearest(&[], 80.0), None);
289    }
290
291    /// Each series' value at the cursor, none where it has a gap there.
292    #[test]
293    fn values_at_the_cursor() {
294        let series = vec![vec![(1.0, 10.0), (2.0, 20.0)], vec![(1.0, -1.0)]];
295        assert_eq!(values_at(&series, 2.0), [Some(20.0), None]);
296        assert_eq!(values_at(&series, 1.0), [Some(10.0), Some(-1.0)]);
297    }
298
299    /// Dates read in full, numbers as the table writes them.
300    #[test]
301    fn the_readout_writes_x_in_full() {
302        let plain = AxisNumbers::default();
303        assert_eq!(
304            format_x(19_783.0, XAxisTemporalKind::Date, &plain),
305            "2024-03-01"
306        );
307        assert_eq!(
308            format_x(1_709_294_400_000.0, XAxisTemporalKind::DatetimeMs, &plain),
309            "2024-03-01 12:00:00"
310        );
311        assert_eq!(format_x(2.5, XAxisTemporalKind::Numeric, &plain), "2.5");
312        assert_eq!(format_x(3.0, XAxisTemporalKind::Numeric, &plain), "3");
313    }
314
315    fn entry(name: &str, value: &str) -> Entry {
316        Entry {
317            name: name.to_string(),
318            name_style: Style::default(),
319            value: value.to_string(),
320            value_style: Style::default(),
321        }
322    }
323
324    fn text(lines: &[Line<'_>]) -> Vec<String> {
325        lines.iter().map(|l| l.to_string()).collect()
326    }
327
328    /// Entries flow onto as many lines as they take, never cut while a line holds one.
329    #[test]
330    fn the_readout_flows_onto_lines() {
331        let g = crate::glyphs::unicode();
332        let entries = [
333            entry("date", "2024-03-01"),
334            entry("temperature", "21.5"),
335            entry("humidity", "44"),
336        ];
337        assert_eq!(
338            text(&readout_lines(&entries, 80, g)),
339            ["date: 2024-03-01   temperature: 21.5   humidity: 44"]
340        );
341        assert_eq!(
342            text(&readout_lines(&entries, 40, g)),
343            ["date: 2024-03-01   temperature: 21.5", "humidity: 44"]
344        );
345        assert_eq!(
346            text(&readout_lines(&entries, 12, g)),
347            ["date: 2024-…", "temperature:", "humidity: 44"]
348        );
349    }
350
351    /// The cursor's line runs down the plot under the series, and its tick sits on
352    /// the axis.
353    #[test]
354    fn the_cursor_line_keeps_the_series_marks() {
355        for g in [crate::glyphs::unicode(), crate::glyphs::ascii()] {
356            let area = Rect::new(0, 0, 20, 6);
357            let mut buf = Buffer::empty(area);
358            let p = PlotPlace {
359                graph: Rect::new(2, 0, 18, 5),
360                x_bounds: [0.0, 17.0],
361                sub: 1,
362            };
363            for x in 0..20 {
364                buf[(x, 5)].set_symbol(g.plot.axis.horizontal);
365            }
366            buf[(7, 2)].set_symbol("x");
367            draw(&mut buf, &p, 5.0, Style::default(), g);
368            let column: String = (0..6).map(|y| buf[(7, y)].symbol().to_string()).collect();
369            let v = g.plot.axis.vertical;
370            assert_eq!(column, format!("{v}{v}x{v}{v}{}", g.plot.tick_x));
371        }
372    }
373}