use crate::color::Rgba;
use crate::error::Result;
use crate::framebuffer::Framebuffer;
use crate::render::draw_line_aa;
use crate::scale::{LinearScale, Scale};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendDirection {
Rising,
Falling,
Stable,
}
impl TrendDirection {
#[must_use]
pub fn indicator(&self) -> &'static str {
match self {
Self::Rising => "\u{2191}", Self::Falling => "\u{2193}", Self::Stable => "\u{2192}", }
}
}
#[derive(Debug, Clone)]
pub struct Sparkline {
data: Vec<f64>,
width: u32,
height: u32,
color: Rgba,
show_trend: bool,
stability_threshold: f64,
}
impl Default for Sparkline {
fn default() -> Self {
Self {
data: Vec::new(),
width: 100,
height: 20,
color: Rgba::rgb(66, 133, 244), show_trend: false,
stability_threshold: 0.05,
}
}
}
impl Sparkline {
#[must_use]
pub fn new(data: &[f64]) -> Self {
Self { data: data.to_vec(), ..Self::default() }
}
#[must_use]
pub fn dimensions(mut self, width: u32, height: u32) -> Self {
self.width = width.max(10);
self.height = height.max(5);
self
}
#[must_use]
pub fn color(mut self, color: Rgba) -> Self {
self.color = color;
self
}
#[must_use]
pub fn with_trend_indicator(mut self) -> Self {
self.show_trend = true;
self
}
#[must_use]
pub fn stability_threshold(mut self, threshold: f64) -> Self {
self.stability_threshold = threshold.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn trend(&self) -> TrendDirection {
if self.data.len() < 2 {
return TrendDirection::Stable;
}
let first = self.data[0];
let last = self.data[self.data.len() - 1];
let change = last - first;
let (min, max) = self.data_extent();
let range = max - min;
if range < f64::EPSILON {
return TrendDirection::Stable;
}
let change_ratio = change.abs() / range;
if change_ratio < self.stability_threshold {
TrendDirection::Stable
} else if change > 0.0 {
TrendDirection::Rising
} else {
TrendDirection::Falling
}
}
#[must_use]
pub fn has_trend_indicator(&self) -> bool {
self.show_trend
}
fn data_extent(&self) -> (f64, f64) {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &value in &self.data {
min = min.min(value);
max = max.max(value);
}
if min.is_infinite() || max.is_infinite() {
return (0.0, 1.0);
}
if (max - min).abs() < f64::EPSILON {
return (min - 0.5, max + 0.5);
}
(min, max)
}
pub fn render(&self, fb: &mut Framebuffer) -> Result<()> {
if self.data.len() < 2 {
return Ok(());
}
let (min, max) = self.data_extent();
let x_scale =
LinearScale::new((0.0, (self.data.len() - 1) as f32), (1.0, (self.width - 2) as f32))?;
let y_scale = LinearScale::new((min as f32, max as f32), ((self.height - 2) as f32, 1.0))?;
for i in 0..self.data.len() - 1 {
let x1 = x_scale.scale(i as f32);
let y1 = y_scale.scale(self.data[i] as f32);
let x2 = x_scale.scale((i + 1) as f32);
let y2 = y_scale.scale(self.data[i + 1] as f32);
draw_line_aa(fb, x1, y1, x2, y2, self.color);
}
Ok(())
}
pub fn to_framebuffer(&self) -> Result<Framebuffer> {
let mut fb = Framebuffer::new(self.width, self.height)?;
fb.clear(Rgba::TRANSPARENT);
self.render(&mut fb)?;
Ok(fb)
}
#[must_use]
pub fn data(&self) -> &[f64] {
&self.data
}
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sparkline_render() {
let sparkline =
Sparkline::new(&[0.9, 0.7, 0.5, 0.3, 0.2]).dimensions(100, 20).color(Rgba::BLUE);
let fb = sparkline.to_framebuffer();
assert!(fb.is_ok());
let fb = fb.expect("operation should succeed");
assert_eq!(fb.width(), 100);
assert_eq!(fb.height(), 20);
}
#[test]
fn test_sparkline_trend_rising() {
let sparkline = Sparkline::new(&[0.1, 0.3, 0.5, 0.7, 0.9]);
assert_eq!(sparkline.trend(), TrendDirection::Rising);
}
#[test]
fn test_sparkline_trend_falling() {
let sparkline = Sparkline::new(&[0.9, 0.7, 0.5, 0.3, 0.1]);
assert_eq!(sparkline.trend(), TrendDirection::Falling);
}
#[test]
fn test_sparkline_trend_stable() {
let sparkline = Sparkline::new(&[0.5, 0.5, 0.5, 0.5, 0.5]);
assert_eq!(sparkline.trend(), TrendDirection::Stable);
let sparkline = Sparkline::new(&[0.5, 0.6, 0.4, 0.55, 0.51]).stability_threshold(0.1);
assert_eq!(sparkline.trend(), TrendDirection::Stable);
}
#[test]
fn test_sparkline_trend_indicator() {
assert_eq!(TrendDirection::Rising.indicator(), "\u{2191}");
assert_eq!(TrendDirection::Falling.indicator(), "\u{2193}");
assert_eq!(TrendDirection::Stable.indicator(), "\u{2192}");
}
#[test]
fn test_sparkline_empty_data() {
let sparkline = Sparkline::new(&[]);
assert_eq!(sparkline.trend(), TrendDirection::Stable);
let fb = sparkline.to_framebuffer();
assert!(fb.is_ok());
}
#[test]
fn test_sparkline_single_point() {
let sparkline = Sparkline::new(&[0.5]);
assert_eq!(sparkline.trend(), TrendDirection::Stable);
}
#[test]
fn test_sparkline_with_trend() {
let sparkline = Sparkline::new(&[0.9, 0.7, 0.5, 0.3, 0.1]).with_trend_indicator();
assert!(sparkline.has_trend_indicator());
assert_eq!(sparkline.trend(), TrendDirection::Falling);
}
#[test]
fn test_sparkline_default() {
let sparkline = Sparkline::default();
assert_eq!(sparkline.trend(), TrendDirection::Stable);
}
#[test]
fn test_sparkline_clone_debug() {
let sparkline = Sparkline::new(&[1.0, 2.0, 3.0]);
let cloned = sparkline.clone();
let debug = format!("{cloned:?}");
assert!(debug.contains("Sparkline"));
}
#[test]
fn test_trend_direction_debug() {
let dirs = [TrendDirection::Rising, TrendDirection::Falling, TrendDirection::Stable];
for dir in dirs {
let debug = format!("{dir:?}");
assert!(!debug.is_empty());
let cloned = dir;
assert_eq!(dir, cloned);
}
}
#[test]
fn test_stability_threshold_clamp() {
let sparkline = Sparkline::new(&[0.0, 1.0]).stability_threshold(2.0);
let fb = sparkline.to_framebuffer();
assert!(fb.is_ok());
let sparkline = Sparkline::new(&[0.0, 1.0]).stability_threshold(-0.5);
let fb = sparkline.to_framebuffer();
assert!(fb.is_ok());
}
#[test]
fn test_dimensions_minimum() {
let sparkline = Sparkline::new(&[0.5, 0.6]).dimensions(1, 1);
let fb = sparkline.to_framebuffer();
assert!(fb.is_ok());
}
}