use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
text::{Line, Span},
};
use unicode_width::UnicodeWidthStr;
use crate::chart_data::{AxisNumbers, XAxisTemporalKind, x_datetime, x_time};
use crate::glyphs::Glyphs;
use crate::widgets::axes::{Track, cut};
const READOUT_GAP: usize = 3;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlotPlace {
pub graph: Rect,
pub x_bounds: [f64; 2],
pub sub: u16,
}
impl PlotPlace {
pub fn column(&self, x: f64) -> u16 {
let [lo, hi] = self.x_bounds;
let f = if hi > lo { (x - lo) / (hi - lo) } else { 0.0 };
Track {
start: self.graph.left(),
cells: self.graph.width,
sub: self.sub,
}
.cell(f)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Move {
Left,
Right,
First,
Last,
}
pub fn xs(series: &[Vec<(f64, f64)>]) -> Vec<f64> {
let mut xs: Vec<f64> = series
.iter()
.flatten()
.map(|&(x, _)| x)
.filter(|x| x.is_finite())
.collect();
xs.sort_by(f64::total_cmp);
xs.dedup();
xs
}
pub fn nearest(xs: &[f64], x: f64) -> Option<f64> {
let i = xs.partition_point(|&v| v < x);
[i.checked_sub(1), Some(i)]
.into_iter()
.flatten()
.filter_map(|i| xs.get(i).copied())
.min_by(|a, b| (a - x).abs().total_cmp(&(b - x).abs()))
}
pub fn step(xs: &[f64], place: &PlotPlace, from: f64, to: Move) -> Option<f64> {
let here = place.column(from);
match to {
Move::First => xs.first().copied(),
Move::Last => xs.last().copied(),
Move::Right => {
let i = xs.partition_point(|&v| place.column(v) <= here);
xs.get(i).copied()
}
Move::Left => {
let i = xs.partition_point(|&v| place.column(v) < here);
i.checked_sub(1).and_then(|prev| {
let column = place.column(xs[prev]);
let first = xs.partition_point(|&v| place.column(v) < column);
xs.get(first).copied()
})
}
}
.or_else(|| nearest(xs, from))
}
pub fn at_column(xs: &[f64], place: &PlotPlace, column: u16) -> Option<f64> {
let i = xs.partition_point(|&v| place.column(v) < column);
[i.checked_sub(1), Some(i)]
.into_iter()
.flatten()
.filter_map(|i| xs.get(i).copied())
.min_by_key(|&v| place.column(v).abs_diff(column))
}
pub fn format_x(x: f64, kind: XAxisTemporalKind, numbers: &AxisNumbers) -> String {
let when = match kind {
XAxisTemporalKind::Numeric => None,
XAxisTemporalKind::Date => x_datetime(x, kind).map(|d| d.format("%Y-%m-%d").to_string()),
XAxisTemporalKind::Time => x_time(x).map(|t| t.format("%H:%M:%S%.f").to_string()),
_ => x_datetime(x, kind).map(|d| d.format("%Y-%m-%d %H:%M:%S%.f").to_string()),
};
when.unwrap_or_else(|| format_number(x, numbers))
}
pub fn format_number(v: f64, numbers: &AxisNumbers) -> String {
let mut out = String::new();
let format = &numbers.format;
if v.fract() == 0.0 || format.float_precision.is_some() || !v.is_finite() {
format.write_f64(v, &mut String::new(), &mut out);
} else {
format.regroup_decimal(&polars::prelude::AnyValue::Float64(v).str_value(), &mut out);
}
out
}
pub fn values_at(series: &[Vec<(f64, f64)>], x: f64) -> Vec<Option<f64>> {
series
.iter()
.map(|points| points.iter().find(|&&(px, _)| px == x).map(|&(_, y)| y))
.collect()
}
pub struct Entry {
pub name: String,
pub name_style: Style,
pub value: String,
pub value_style: Style,
}
impl Entry {
fn width(&self) -> usize {
self.name.width() + 2 + self.value.width()
}
}
pub fn readout_lines(entries: &[Entry], width: usize, g: &Glyphs) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut used = 0;
for entry in entries {
let w = entry.width();
if lines.is_empty() || used + READOUT_GAP + w > width {
lines.push(Line::default());
used = 0;
} else {
if let Some(line) = lines.last_mut() {
line.push_span(Span::raw(" ".repeat(READOUT_GAP)));
}
used += READOUT_GAP;
}
let line = lines.last_mut().expect("a line was pushed");
let name = cut(&format!("{}:", entry.name), width, g);
let room = width.saturating_sub(name.width() + 1);
used += name.width();
line.push_span(Span::styled(name, entry.name_style));
if room > 0 {
let value = cut(&entry.value, room, g);
used += 1 + value.width();
line.push_span(Span::raw(" "));
line.push_span(Span::styled(value, entry.value_style));
}
}
lines
}
pub fn draw(buf: &mut Buffer, place: &PlotPlace, x: f64, style: Style, g: &Glyphs) {
let graph = place.graph;
if graph.is_empty() {
return;
}
let column = place.column(x);
for y in graph.top()..graph.bottom() {
let cell = &mut buf[(column, y)];
let symbol = cell.symbol();
if matches!(symbol, " " | "\u{2800}")
|| symbol == g.plot.grid_across
|| symbol == g.plot.grid_down
{
cell.set_symbol(g.plot.axis.vertical).set_style(style);
}
}
let axis = graph.bottom();
if axis < buf.area.bottom() {
let cell = &mut buf[(column, axis)];
if cell.symbol() == g.plot.axis.horizontal || cell.symbol() == g.plot.tick_x {
cell.set_symbol(g.plot.tick_x).set_style(style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn readout_numbers_read_as_the_table_writes_them() {
let plain = AxisNumbers::default();
assert_eq!(format_number(19.434782608695652, &plain), "19.434783");
assert_eq!(format_number(-0.5, &plain), "-0.5");
let grouped = AxisNumbers {
format: crate::numfmt::NumberFormat::preset("thousands").unwrap(),
whole: false,
};
assert_eq!(format_number(12345.678901234, &grouped), "12,345.678901");
let whole = AxisNumbers {
whole: true,
..grouped
};
assert_eq!(format_number(1234.0, &whole), "1,234");
}
fn place(width: u16) -> PlotPlace {
PlotPlace {
graph: Rect::new(10, 0, width, 10),
x_bounds: [0.0, 100.0],
sub: 1,
}
}
#[test]
fn the_cursor_steps_point_to_point_and_column_to_column() {
let sparse = xs(&[vec![(0.0, 1.0), (50.0, 2.0), (100.0, 3.0)]]);
let p = place(101);
assert_eq!(step(&sparse, &p, 0.0, Move::Right), Some(50.0));
assert_eq!(step(&sparse, &p, 50.0, Move::Right), Some(100.0));
assert_eq!(step(&sparse, &p, 100.0, Move::Right), Some(100.0), "stays");
assert_eq!(step(&sparse, &p, 50.0, Move::Left), Some(0.0));
assert_eq!(step(&sparse, &p, 0.0, Move::Left), Some(0.0), "stays");
let dense: Vec<f64> = (0..1000).map(|i| f64::from(i) / 10.0).collect();
let p = place(11);
let mut at = 0.0;
let mut columns = vec![p.column(at)];
while let Some(next) = step(&dense, &p, at, Move::Right).filter(|&n| n != at) {
at = next;
columns.push(p.column(at));
}
assert_eq!(columns, (10..21).collect::<Vec<u16>>());
let back = step(&dense, &p, at, Move::Left).unwrap();
assert_eq!(p.column(back), 19);
assert_eq!(step(&dense, &p, back, Move::Right), Some(at));
assert_eq!(step(&dense, &p, 42.0, Move::First), Some(0.0));
assert_eq!(step(&dense, &p, 42.0, Move::Last), Some(99.9));
}
#[test]
fn a_click_lands_on_the_nearest_point() {
let sparse = xs(&[vec![(0.0, 1.0), (50.0, 2.0)], vec![(100.0, 3.0)]]);
let p = place(101);
assert_eq!(at_column(&sparse, &p, 10), Some(0.0));
assert_eq!(at_column(&sparse, &p, 40), Some(50.0));
assert_eq!(at_column(&sparse, &p, 300), Some(100.0));
assert_eq!(nearest(&sparse, 80.0), Some(100.0));
assert_eq!(nearest(&[], 80.0), None);
}
#[test]
fn values_at_the_cursor() {
let series = vec![vec![(1.0, 10.0), (2.0, 20.0)], vec![(1.0, -1.0)]];
assert_eq!(values_at(&series, 2.0), [Some(20.0), None]);
assert_eq!(values_at(&series, 1.0), [Some(10.0), Some(-1.0)]);
}
#[test]
fn the_readout_writes_x_in_full() {
let plain = AxisNumbers::default();
assert_eq!(
format_x(19_783.0, XAxisTemporalKind::Date, &plain),
"2024-03-01"
);
assert_eq!(
format_x(1_709_294_400_000.0, XAxisTemporalKind::DatetimeMs, &plain),
"2024-03-01 12:00:00"
);
assert_eq!(format_x(2.5, XAxisTemporalKind::Numeric, &plain), "2.5");
assert_eq!(format_x(3.0, XAxisTemporalKind::Numeric, &plain), "3");
}
fn entry(name: &str, value: &str) -> Entry {
Entry {
name: name.to_string(),
name_style: Style::default(),
value: value.to_string(),
value_style: Style::default(),
}
}
fn text(lines: &[Line<'_>]) -> Vec<String> {
lines.iter().map(|l| l.to_string()).collect()
}
#[test]
fn the_readout_flows_onto_lines() {
let g = crate::glyphs::unicode();
let entries = [
entry("date", "2024-03-01"),
entry("temperature", "21.5"),
entry("humidity", "44"),
];
assert_eq!(
text(&readout_lines(&entries, 80, g)),
["date: 2024-03-01 temperature: 21.5 humidity: 44"]
);
assert_eq!(
text(&readout_lines(&entries, 40, g)),
["date: 2024-03-01 temperature: 21.5", "humidity: 44"]
);
assert_eq!(
text(&readout_lines(&entries, 12, g)),
["date: 2024-…", "temperature:", "humidity: 44"]
);
}
#[test]
fn the_cursor_line_keeps_the_series_marks() {
for g in [crate::glyphs::unicode(), crate::glyphs::ascii()] {
let area = Rect::new(0, 0, 20, 6);
let mut buf = Buffer::empty(area);
let p = PlotPlace {
graph: Rect::new(2, 0, 18, 5),
x_bounds: [0.0, 17.0],
sub: 1,
};
for x in 0..20 {
buf[(x, 5)].set_symbol(g.plot.axis.horizontal);
}
buf[(7, 2)].set_symbol("x");
draw(&mut buf, &p, 5.0, Style::default(), g);
let column: String = (0..6).map(|y| buf[(7, y)].symbol().to_string()).collect();
let v = g.plot.axis.vertical;
assert_eq!(column, format!("{v}{v}x{v}{v}{}", g.plot.tick_x));
}
}
}