use presentar_core::{
Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
};
use std::any::Any;
use std::time::Duration;
const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrendDirection {
Up,
Down,
#[default]
Flat,
}
impl TrendDirection {
#[must_use]
pub const fn arrow(&self) -> char {
match self {
Self::Up => '↑',
Self::Down => '↓',
Self::Flat => '→',
}
}
#[must_use]
pub fn color(&self) -> Color {
match self {
Self::Up => Color::new(0.3, 1.0, 0.5, 1.0), Self::Down => Color::new(1.0, 0.3, 0.3, 1.0), Self::Flat => Color::new(0.7, 0.7, 0.7, 1.0), }
}
}
#[derive(Debug, Clone)]
pub struct Sparkline {
data: Vec<f64>,
min: f64,
max: f64,
color: Color,
show_trend: bool,
show_y_axis: bool,
y_format: Option<String>,
bounds: Rect,
}
impl Default for Sparkline {
fn default() -> Self {
Self::new(vec![])
}
}
impl Sparkline {
#[must_use]
pub fn new(data: Vec<f64>) -> Self {
let (min, max) = Self::compute_range(&data);
Self {
data,
min,
max,
color: Color::new(0.3, 0.7, 1.0, 1.0),
show_trend: false,
show_y_axis: false,
y_format: None,
bounds: Rect::default(),
}
}
#[must_use]
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
#[must_use]
pub fn with_range(mut self, min: f64, max: f64) -> Self {
debug_assert!(min.is_finite(), "min must be finite");
debug_assert!(max.is_finite(), "max must be finite");
self.min = min;
self.max = max.max(min + 0.001);
self
}
#[must_use]
pub fn with_trend(mut self, show: bool) -> Self {
self.show_trend = show;
self
}
#[must_use]
pub fn with_y_axis(mut self, show: bool) -> Self {
self.show_y_axis = show;
self
}
#[must_use]
pub fn with_y_format(mut self, format: impl Into<String>) -> Self {
self.y_format = Some(format.into());
self.show_y_axis = true;
self
}
#[must_use]
#[allow(clippy::literal_string_with_formatting_args)]
pub fn y_axis_width(&self) -> u16 {
if !self.show_y_axis {
return 0;
}
let max_label = if let Some(ref fmt) = self.y_format {
fmt.replace("{:.0}", "999").replace("{:.1}", "99.9")
} else {
format!("{:.0}", self.max.abs().max(self.min.abs()))
};
(max_label.len() + 1) as u16
}
pub fn set_data(&mut self, data: Vec<f64>) {
let (min, max) = Self::compute_range(&data);
self.data = data;
self.min = min;
self.max = max;
}
#[must_use]
pub fn trend(&self) -> TrendDirection {
if self.data.len() < 2 {
return TrendDirection::Flat;
}
let recent = self.data.len().saturating_sub(3);
let recent_avg: f64 =
self.data[recent..].iter().sum::<f64>() / (self.data.len() - recent) as f64;
let older_end = recent.min(self.data.len());
let older_start = older_end.saturating_sub(3);
if older_start >= older_end {
return TrendDirection::Flat;
}
let older_avg: f64 = self.data[older_start..older_end].iter().sum::<f64>()
/ (older_end - older_start) as f64;
let threshold = (self.max - self.min) * 0.05;
if recent_avg > older_avg + threshold {
TrendDirection::Up
} else if recent_avg < older_avg - threshold {
TrendDirection::Down
} else {
TrendDirection::Flat
}
}
fn compute_range(data: &[f64]) -> (f64, f64) {
if data.is_empty() {
return (0.0, 1.0);
}
let min = data.iter().fold(f64::MAX, |a, &b| a.min(b));
let max = data.iter().fold(f64::MIN, |a, &b| a.max(b));
if (max - min).abs() < f64::EPSILON {
(min - 0.5, max + 0.5)
} else {
(min, max)
}
}
fn normalize(&self, value: f64) -> f64 {
let range = self.max - self.min;
if range.abs() < f64::EPSILON {
0.5
} else {
((value - self.min) / range).clamp(0.0, 1.0)
}
}
}
impl Brick for Sparkline {
fn brick_name(&self) -> &'static str {
"sparkline"
}
fn assertions(&self) -> &[BrickAssertion] {
static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
ASSERTIONS
}
fn budget(&self) -> BrickBudget {
BrickBudget::uniform(16)
}
fn verify(&self) -> BrickVerification {
BrickVerification {
passed: self.assertions().to_vec(),
failed: vec![],
verification_time: Duration::from_micros(5),
}
}
fn to_html(&self) -> String {
String::new()
}
fn to_css(&self) -> String {
String::new()
}
}
impl Widget for Sparkline {
fn type_id(&self) -> TypeId {
TypeId::of::<Self>()
}
fn measure(&self, constraints: Constraints) -> Size {
let width = (self.data.len() as f32 + if self.show_trend { 2.0 } else { 0.0 })
.min(constraints.max_width)
.max(1.0);
constraints.constrain(Size::new(width, 1.0))
}
fn layout(&mut self, bounds: Rect) -> LayoutResult {
self.bounds = bounds;
LayoutResult {
size: Size::new(bounds.width, bounds.height.max(1.0)),
}
}
fn paint(&self, canvas: &mut dyn Canvas) {
if self.data.is_empty() || self.bounds.width < 1.0 {
return;
}
let available_width = if self.show_trend {
(self.bounds.width as usize).saturating_sub(2)
} else {
self.bounds.width as usize
};
if available_width == 0 {
return;
}
let mut spark = String::with_capacity(available_width);
for i in 0..available_width.min(self.data.len()) {
let idx = (i * self.data.len()) / available_width;
let value = self.data.get(idx).copied().unwrap_or(0.0);
let norm = self.normalize(value);
let char_idx = ((norm * 7.0).round() as usize).min(7);
spark.push(SPARK_CHARS[char_idx]);
}
let style = TextStyle {
color: self.color,
..Default::default()
};
canvas.draw_text(&spark, Point::new(self.bounds.x, self.bounds.y), &style);
if self.show_trend {
let trend = self.trend();
let trend_style = TextStyle {
color: trend.color(),
..Default::default()
};
canvas.draw_text(
&format!(" {}", trend.arrow()),
Point::new(self.bounds.x + available_width as f32, self.bounds.y),
&trend_style,
);
}
}
fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
None
}
fn children(&self) -> &[Box<dyn Widget>] {
&[]
}
fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
&mut []
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockCanvas {
texts: Vec<(String, Point)>,
}
impl MockCanvas {
fn new() -> Self {
Self { texts: vec![] }
}
}
impl Canvas for MockCanvas {
fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
self.texts.push((text.to_string(), position));
}
fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
fn push_clip(&mut self, _rect: Rect) {}
fn pop_clip(&mut self) {}
fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
fn pop_transform(&mut self) {}
}
#[test]
fn test_sparkline_creation() {
let spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(spark.data.len(), 5);
}
#[test]
fn test_sparkline_assertions() {
let spark = Sparkline::new(vec![1.0]);
assert!(!spark.assertions().is_empty());
}
#[test]
fn test_sparkline_verify() {
let spark = Sparkline::new(vec![1.0, 2.0]);
assert!(spark.verify().is_valid());
}
#[test]
fn test_sparkline_with_color() {
let spark = Sparkline::new(vec![1.0]).with_color(Color::RED);
assert_eq!(spark.color, Color::RED);
}
#[test]
fn test_sparkline_with_range() {
let spark = Sparkline::new(vec![1.0]).with_range(0.0, 100.0);
assert_eq!(spark.min, 0.0);
assert_eq!(spark.max, 100.0);
}
#[test]
fn test_sparkline_with_trend() {
let spark = Sparkline::new(vec![1.0]).with_trend(true);
assert!(spark.show_trend);
}
#[test]
fn test_sparkline_trend_up() {
let spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
assert_eq!(spark.trend(), TrendDirection::Up);
}
#[test]
fn test_sparkline_trend_down() {
let spark = Sparkline::new(vec![8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]);
assert_eq!(spark.trend(), TrendDirection::Down);
}
#[test]
fn test_sparkline_trend_flat() {
let spark = Sparkline::new(vec![5.0, 5.0, 5.0, 5.0, 5.0]);
assert_eq!(spark.trend(), TrendDirection::Flat);
}
#[test]
fn test_sparkline_paint() {
let mut spark = Sparkline::new(vec![0.0, 0.5, 1.0]);
spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
let mut canvas = MockCanvas::new();
spark.paint(&mut canvas);
assert!(!canvas.texts.is_empty());
}
#[test]
fn test_sparkline_paint_with_trend() {
let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_trend(true);
spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
let mut canvas = MockCanvas::new();
spark.paint(&mut canvas);
assert!(canvas.texts.len() >= 1);
}
#[test]
fn test_sparkline_empty() {
let mut spark = Sparkline::new(vec![]);
spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
let mut canvas = MockCanvas::new();
spark.paint(&mut canvas);
assert!(canvas.texts.is_empty());
}
#[test]
fn test_sparkline_measure() {
let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
let size = spark.measure(Constraints::loose(Size::new(100.0, 10.0)));
assert!(size.width >= 3.0);
assert_eq!(size.height, 1.0);
}
#[test]
fn test_sparkline_layout() {
let mut spark = Sparkline::new(vec![1.0, 2.0]);
let bounds = Rect::new(5.0, 10.0, 20.0, 1.0);
let result = spark.layout(bounds);
assert_eq!(result.size.width, 20.0);
assert_eq!(spark.bounds, bounds);
}
#[test]
fn test_trend_direction_arrow() {
assert_eq!(TrendDirection::Up.arrow(), '↑');
assert_eq!(TrendDirection::Down.arrow(), '↓');
assert_eq!(TrendDirection::Flat.arrow(), '→');
}
#[test]
fn test_trend_direction_color() {
let _ = TrendDirection::Up.color();
let _ = TrendDirection::Down.color();
let _ = TrendDirection::Flat.color();
}
#[test]
fn test_sparkline_set_data() {
let mut spark = Sparkline::new(vec![1.0]);
spark.set_data(vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(spark.data.len(), 4);
}
#[test]
fn test_sparkline_brick_name() {
let spark = Sparkline::new(vec![]);
assert_eq!(spark.brick_name(), "sparkline");
}
#[test]
fn test_sparkline_budget() {
let spark = Sparkline::new(vec![]);
let budget = spark.budget();
assert!(budget.paint_ms > 0);
}
#[test]
fn test_sparkline_type_id() {
let spark = Sparkline::new(vec![]);
assert_eq!(Widget::type_id(&spark), TypeId::of::<Sparkline>());
}
#[test]
fn test_sparkline_children() {
let spark = Sparkline::new(vec![]);
assert!(spark.children().is_empty());
}
#[test]
fn test_sparkline_children_mut() {
let mut spark = Sparkline::new(vec![]);
assert!(spark.children_mut().is_empty());
}
#[test]
fn test_sparkline_event() {
let mut spark = Sparkline::new(vec![]);
let event = Event::KeyDown {
key: presentar_core::Key::Enter,
};
assert!(spark.event(&event).is_none());
}
#[test]
fn test_sparkline_default() {
let spark = Sparkline::default();
assert!(spark.data.is_empty());
}
#[test]
fn test_sparkline_to_html() {
let spark = Sparkline::new(vec![]);
assert!(spark.to_html().is_empty());
}
#[test]
fn test_sparkline_to_css() {
let spark = Sparkline::new(vec![]);
assert!(spark.to_css().is_empty());
}
#[test]
fn test_sparkline_trend_single_value() {
let spark = Sparkline::new(vec![5.0]);
assert_eq!(spark.trend(), TrendDirection::Flat);
}
#[test]
fn test_sparkline_trend_two_values() {
let spark = Sparkline::new(vec![1.0, 2.0]);
assert_eq!(spark.trend(), TrendDirection::Flat);
}
#[test]
fn test_sparkline_trend_three_values() {
let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
assert_eq!(spark.trend(), TrendDirection::Flat);
}
#[test]
fn test_sparkline_normalize_zero_range() {
let spark = Sparkline::new(vec![5.0, 5.0, 5.0]);
let normalized = spark.normalize(5.0);
assert!((normalized - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_sparkline_paint_zero_available_width() {
let mut spark = Sparkline::new(vec![1.0, 2.0]).with_trend(true);
spark.bounds = Rect::new(0.0, 0.0, 2.0, 1.0); let mut canvas = MockCanvas::new();
spark.paint(&mut canvas);
}
#[test]
fn test_sparkline_paint_narrow_width() {
let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0]).with_trend(true);
spark.bounds = Rect::new(0.0, 0.0, 1.0, 1.0);
let mut canvas = MockCanvas::new();
spark.paint(&mut canvas);
}
#[test]
fn test_sparkline_with_y_axis() {
let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(true);
assert!(spark.show_y_axis);
}
#[test]
fn test_sparkline_with_y_axis_false() {
let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(false);
assert!(!spark.show_y_axis);
}
#[test]
fn test_sparkline_with_y_format() {
let spark = Sparkline::new(vec![1.0, 2.0]).with_y_format("{:.0}%");
assert!(spark.show_y_axis);
assert_eq!(spark.y_format, Some("{:.0}%".to_string()));
}
#[test]
fn test_sparkline_y_axis_width_no_axis() {
let spark = Sparkline::new(vec![1.0, 2.0]);
assert_eq!(spark.y_axis_width(), 0);
}
#[test]
fn test_sparkline_y_axis_width_with_axis() {
let spark = Sparkline::new(vec![1.0, 100.0]).with_y_axis(true);
let width = spark.y_axis_width();
assert!(width > 0);
}
#[test]
fn test_sparkline_y_axis_width_with_format() {
let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.0}%");
let width = spark.y_axis_width();
assert!(width > 0);
}
#[test]
fn test_sparkline_y_axis_width_with_format_decimal() {
let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.1}ms");
let width = spark.y_axis_width();
assert!(width > 0);
}
}