use std::cell::RefCell;
use std::rc::Rc;
use rdom_tui::runtime::builtins::canvas::{self, RenderContext};
use rdom_tui::{Color, NodeId, TuiDom};
use super::braille::BrailleGrid;
use crate::palette::series_color;
pub struct Sparkline {
values: Vec<f64>,
color: Color,
min: Option<f64>,
max: Option<f64>,
}
impl Sparkline {
pub fn new(values: Vec<f64>) -> Self {
Self {
values,
color: series_color(0),
min: None,
max: None,
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn with_range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn set_values(&mut self, values: Vec<f64>) {
self.values = values;
}
fn effective_range(&self) -> (f64, f64) {
let mut lo = self.min.unwrap_or(f64::INFINITY);
let mut hi = self.max.unwrap_or(f64::NEG_INFINITY);
if self.min.is_none() || self.max.is_none() {
for &v in &self.values {
if v.is_finite() {
if self.min.is_none() {
lo = lo.min(v);
}
if self.max.is_none() {
hi = hi.max(v);
}
}
}
}
if !lo.is_finite() || !hi.is_finite() {
return (0.0, 1.0);
}
if (hi - lo).abs() < 1e-12 {
let pad = if lo.abs() < 1e-12 {
1.0
} else {
lo.abs() * 0.1
};
return (lo - pad, hi + pad);
}
(lo, hi)
}
fn scale(&self, bw: i32, bh: i32) -> Vec<Option<(i32, i32)>> {
let n = self.values.len();
let (lo, hi) = self.effective_range();
let range = hi - lo;
self.values
.iter()
.enumerate()
.map(|(i, &v)| {
if !v.is_finite() {
return None;
}
let x_frac = if n > 1 {
i as f64 / (n - 1) as f64
} else {
0.5
};
let v_frac = if range > 0.0 { (v - lo) / range } else { 0.5 };
Some((
(x_frac * (bw - 1) as f64).round() as i32,
((1.0 - v_frac) * (bh - 1) as f64).round() as i32,
))
})
.collect()
}
pub fn paint(&self, ctx: &mut RenderContext<'_>) {
let w = ctx.width();
let h = ctx.height();
if w == 0 || h == 0 || self.values.is_empty() {
return;
}
let mut grid = BrailleGrid::new(w, h);
let pts = self.scale(grid.braille_width(), grid.braille_height());
let mut prev: Option<(i32, i32)> = None;
for p in pts {
match p {
Some((bx, by)) => {
grid.set_dot(bx, by, self.color);
if let Some((px, py)) = prev {
grid.draw_line(px, py, bx, by, self.color);
}
prev = Some((bx, by));
}
None => prev = None,
}
}
grid.paint(ctx);
}
}
#[derive(Clone)]
pub struct SparklineView {
inner: Rc<RefCell<Sparkline>>,
}
impl SparklineView {
pub fn new(sparkline: Sparkline) -> Self {
Self {
inner: Rc::new(RefCell::new(sparkline)),
}
}
pub fn mount(&self, dom: &mut TuiDom) -> NodeId {
let id = dom.create_element("canvas");
let inner = self.inner.clone();
canvas::set_paint(dom, id, move |_dom, ctx| {
inner.borrow().paint(ctx);
});
id
}
pub fn with<R>(&self, f: impl FnOnce(&mut Sparkline) -> R) -> R {
f(&mut self.inner.borrow_mut())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scale_maps_within_grid_bounds() {
let s = Sparkline::new((0..20).map(|i| (i as f64 * 0.5).sin()).collect());
let (bw, bh) = (80, 40);
for p in s.scale(bw, bh).into_iter().flatten() {
assert!(p.0 >= 0 && p.0 < bw, "x {} out of [0,{bw})", p.0);
assert!(p.1 >= 0 && p.1 < bh, "y {} out of [0,{bh})", p.1);
}
}
#[test]
fn scale_endpoints_span_width() {
let s = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0]);
let pts = s.scale(80, 40);
assert_eq!(pts.first().unwrap().unwrap().0, 0);
assert_eq!(pts.last().unwrap().unwrap().0, 79);
}
#[test]
fn min_value_sits_at_bottom_max_at_top() {
let s = Sparkline::new(vec![0.0, 10.0]);
let pts = s.scale(80, 40);
let first = pts[0].unwrap();
let last = pts[1].unwrap();
assert_eq!(first.1, 39, "min should map to the bottom braille row");
assert_eq!(last.1, 0, "max should map to the top braille row");
}
#[test]
fn nan_is_a_gap() {
let s = Sparkline::new(vec![1.0, f64::NAN, 3.0]);
let pts = s.scale(80, 40);
assert!(pts[0].is_some());
assert!(pts[1].is_none());
assert!(pts[2].is_some());
}
#[test]
fn single_value_centers_horizontally() {
let s = Sparkline::new(vec![5.0]);
let pts = s.scale(80, 40);
assert_eq!(pts[0].unwrap().0, 40);
}
#[test]
fn range_override_clamps_mapping() {
let s = Sparkline::new(vec![50.0]).with_range(0.0, 100.0);
let pts = s.scale(80, 40);
let y = pts[0].unwrap().1;
assert!(
(18..=21).contains(&y),
"midpoint should be mid-height, got {y}"
);
}
}