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::chart_data::{XAxisTemporalKind, x_datetime, x_time};
14use crate::glyphs::Glyphs;
15use crate::widgets::axes::{Track, cut};
16use crate::widgets::axis_numbers::AxisNumbers;
17
18/// Cells between two entries of the readout.
19const READOUT_GAP: usize = 3;
20
21/// Where an XY plot was drawn: its cells and the x range across them, as the series'
22/// marker divides each cell.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct PlotPlace {
25    pub graph: Rect,
26    pub x_bounds: [f64; 2],
27    /// Dots across a cell.
28    pub sub: u16,
29}
30
31impl PlotPlace {
32    /// The column `x` is drawn in, as the series' points are.
33    pub fn column(&self, x: f64) -> u16 {
34        let [lo, hi] = self.x_bounds;
35        let f = if hi > lo { (x - lo) / (hi - lo) } else { 0.0 };
36        Track {
37            start: self.graph.left(),
38            cells: self.graph.width,
39            sub: self.sub,
40        }
41        .cell(f)
42    }
43}
44
45/// A move of the cursor.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Move {
48    Left,
49    Right,
50    First,
51    Last,
52}
53
54/// The x of every point of every series, in order, each once.
55pub fn xs(series: &[Vec<(f64, f64)>]) -> Vec<f64> {
56    let mut xs: Vec<f64> = series
57        .iter()
58        .flatten()
59        .map(|&(x, _)| x)
60        .filter(|x| x.is_finite())
61        .collect();
62    xs.sort_by(f64::total_cmp);
63    xs.dedup();
64    xs
65}
66
67/// The x among `xs` nearest `x`.
68pub fn nearest(xs: &[f64], x: f64) -> Option<f64> {
69    let i = xs.partition_point(|&v| v < x);
70    [i.checked_sub(1), Some(i)]
71        .into_iter()
72        .flatten()
73        .filter_map(|i| xs.get(i).copied())
74        .min_by(|a, b| (a - x).abs().total_cmp(&(b - x).abs()))
75}
76
77/// Where the cursor at `from` goes: to the first point of the next column that has
78/// one, so sparse points are stepped one by one and dense ones a column at a time;
79/// or to the first or last point.
80pub fn step(xs: &[f64], place: &PlotPlace, from: f64, to: Move) -> Option<f64> {
81    let here = place.column(from);
82    match to {
83        Move::First => xs.first().copied(),
84        Move::Last => xs.last().copied(),
85        Move::Right => {
86            let i = xs.partition_point(|&v| place.column(v) <= here);
87            xs.get(i).copied()
88        }
89        Move::Left => {
90            let i = xs.partition_point(|&v| place.column(v) < here);
91            // The first point of that column, so stepping back and forth lands alike.
92            i.checked_sub(1).and_then(|prev| {
93                let column = place.column(xs[prev]);
94                let first = xs.partition_point(|&v| place.column(v) < column);
95                xs.get(first).copied()
96            })
97        }
98    }
99    .or_else(|| nearest(xs, from))
100}
101
102/// The point drawn nearest `column`: a click lands on what it was aimed at.
103pub fn at_column(xs: &[f64], place: &PlotPlace, column: u16) -> Option<f64> {
104    let i = xs.partition_point(|&v| place.column(v) < column);
105    [i.checked_sub(1), Some(i)]
106        .into_iter()
107        .flatten()
108        .filter_map(|i| xs.get(i).copied())
109        .min_by_key(|&v| place.column(v).abs_diff(column))
110}
111
112/// `x` as the readout writes it: in full, a date or time to the second, a number as
113/// the table writes its column.
114pub fn format_x(x: f64, kind: XAxisTemporalKind, numbers: &AxisNumbers) -> String {
115    let when = match kind {
116        XAxisTemporalKind::Numeric => None,
117        XAxisTemporalKind::Date => x_datetime(x, kind).map(|d| d.format("%Y-%m-%d").to_string()),
118        XAxisTemporalKind::Time => x_time(x).map(|t| t.format("%H:%M:%S%.f").to_string()),
119        _ => x_datetime(x, kind).map(|d| d.format("%Y-%m-%d %H:%M:%S%.f").to_string()),
120    };
121    when.unwrap_or_else(|| numbers.write(x))
122}
123
124/// Each series' value at `x`, by its points before any log: `None` where it has no
125/// point there, a gap in its line.
126pub fn values_at(series: &[Vec<(f64, f64)>], x: f64) -> Vec<Option<f64>> {
127    series
128        .iter()
129        .map(|points| points.iter().find(|&&(px, _)| px == x).map(|&(_, y)| y))
130        .collect()
131}
132
133/// One entry of the readout: a name in its color and the value beside it.
134pub struct Entry {
135    pub name: String,
136    pub name_style: Style,
137    pub value: String,
138    pub value_style: Style,
139}
140
141impl Entry {
142    fn width(&self) -> usize {
143        self.name.width() + 2 + self.value.width()
144    }
145}
146
147/// The readout's lines in `width` cells: entries flow left to right, three cells
148/// apart, onto as many lines as they take; an entry wider than a line is cut.
149pub fn readout_lines(entries: &[Entry], width: usize, g: &Glyphs) -> Vec<Line<'static>> {
150    let mut lines: Vec<Line<'static>> = Vec::new();
151    let mut used = 0;
152    for entry in entries {
153        let w = entry.width();
154        if lines.is_empty() || used + READOUT_GAP + w > width {
155            lines.push(Line::default());
156            used = 0;
157        } else {
158            if let Some(line) = lines.last_mut() {
159                line.push_span(Span::raw(" ".repeat(READOUT_GAP)));
160            }
161            used += READOUT_GAP;
162        }
163        let line = lines.last_mut().expect("a line was pushed");
164        let name = cut(&format!("{}:", entry.name), width, g);
165        let room = width.saturating_sub(name.width() + 1);
166        used += name.width();
167        line.push_span(Span::styled(name, entry.name_style));
168        if room > 0 {
169            let value = cut(&entry.value, room, g);
170            used += 1 + value.width();
171            line.push_span(Span::raw(" "));
172            line.push_span(Span::styled(value, entry.value_style));
173        }
174    }
175    lines
176}
177
178/// The cursor: a line down the plot at `x`'s column, under the series, whose marks
179/// stay where they fall; and its tick on the x axis.
180pub fn draw(buf: &mut Buffer, place: &PlotPlace, x: f64, style: Style, g: &Glyphs) {
181    let graph = place.graph;
182    if graph.is_empty() {
183        return;
184    }
185    let column = place.column(x);
186    for y in graph.top()..graph.bottom() {
187        let cell = &mut buf[(column, y)];
188        let symbol = cell.symbol();
189        if matches!(symbol, " " | "\u{2800}")
190            || symbol == g.plot.grid_across
191            || symbol == g.plot.grid_down
192        {
193            cell.set_symbol(g.plot.axis.vertical).set_style(style);
194        }
195    }
196    let axis = graph.bottom();
197    if axis < buf.area.bottom() {
198        let cell = &mut buf[(column, axis)];
199        if cell.symbol() == g.plot.axis.horizontal || cell.symbol() == g.plot.tick_x {
200            cell.set_symbol(g.plot.tick_x).set_style(style);
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests;