1use 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
17const READOUT_GAP: usize = 3;
19
20#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct PlotPlace {
24 pub graph: Rect,
25 pub x_bounds: [f64; 2],
26 pub sub: u16,
28}
29
30impl PlotPlace {
31 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum Move {
47 Left,
48 Right,
49 First,
50 Last,
51}
52
53pub 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
66pub 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
76pub 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 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
101pub 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
111pub 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
123pub 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
137pub 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
146pub 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
160pub 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
191pub 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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}