1use presentar_core::{
7 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8 LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TrendDirection {
19 Up,
21 Down,
23 #[default]
25 Flat,
26}
27
28impl TrendDirection {
29 #[must_use]
31 pub const fn arrow(&self) -> char {
32 match self {
33 Self::Up => '↑',
34 Self::Down => '↓',
35 Self::Flat => '→',
36 }
37 }
38
39 #[must_use]
41 pub fn color(&self) -> Color {
42 match self {
43 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), }
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct Sparkline {
53 data: Vec<f64>,
55 min: f64,
57 max: f64,
59 color: Color,
61 show_trend: bool,
63 show_y_axis: bool,
65 y_format: Option<String>,
67 bounds: Rect,
69}
70
71impl Default for Sparkline {
72 fn default() -> Self {
73 Self::new(vec![])
74 }
75}
76
77impl Sparkline {
78 #[must_use]
80 pub fn new(data: Vec<f64>) -> Self {
81 let (min, max) = Self::compute_range(&data);
82 Self {
83 data,
84 min,
85 max,
86 color: Color::new(0.3, 0.7, 1.0, 1.0),
87 show_trend: false,
88 show_y_axis: false,
89 y_format: None,
90 bounds: Rect::default(),
91 }
92 }
93
94 #[must_use]
96 pub fn with_color(mut self, color: Color) -> Self {
97 self.color = color;
98 self
99 }
100
101 #[must_use]
103 pub fn with_range(mut self, min: f64, max: f64) -> Self {
104 debug_assert!(min.is_finite(), "min must be finite");
106 debug_assert!(max.is_finite(), "max must be finite");
107 self.min = min;
108 self.max = max.max(min + 0.001);
109 self
110 }
111
112 #[must_use]
114 pub fn with_trend(mut self, show: bool) -> Self {
115 self.show_trend = show;
116 self
117 }
118
119 #[must_use]
121 pub fn with_y_axis(mut self, show: bool) -> Self {
122 self.show_y_axis = show;
123 self
124 }
125
126 #[must_use]
128 pub fn with_y_format(mut self, format: impl Into<String>) -> Self {
129 self.y_format = Some(format.into());
130 self.show_y_axis = true;
131 self
132 }
133
134 #[must_use]
136 #[allow(clippy::literal_string_with_formatting_args)]
137 pub fn y_axis_width(&self) -> u16 {
138 if !self.show_y_axis {
139 return 0;
140 }
141 let max_label = if let Some(ref fmt) = self.y_format {
143 fmt.replace("{:.0}", "999").replace("{:.1}", "99.9")
144 } else {
145 format!("{:.0}", self.max.abs().max(self.min.abs()))
146 };
147 (max_label.len() + 1) as u16
148 }
149
150 pub fn set_data(&mut self, data: Vec<f64>) {
152 let (min, max) = Self::compute_range(&data);
153 self.data = data;
154 self.min = min;
155 self.max = max;
156 }
157
158 #[must_use]
160 pub fn trend(&self) -> TrendDirection {
161 if self.data.len() < 2 {
162 return TrendDirection::Flat;
163 }
164
165 let recent = self.data.len().saturating_sub(3);
166 let recent_avg: f64 =
167 self.data[recent..].iter().sum::<f64>() / (self.data.len() - recent) as f64;
168
169 let older_end = recent.min(self.data.len());
170 let older_start = older_end.saturating_sub(3);
171 if older_start >= older_end {
172 return TrendDirection::Flat;
173 }
174 let older_avg: f64 = self.data[older_start..older_end].iter().sum::<f64>()
175 / (older_end - older_start) as f64;
176
177 let threshold = (self.max - self.min) * 0.05;
178 if recent_avg > older_avg + threshold {
179 TrendDirection::Up
180 } else if recent_avg < older_avg - threshold {
181 TrendDirection::Down
182 } else {
183 TrendDirection::Flat
184 }
185 }
186
187 fn compute_range(data: &[f64]) -> (f64, f64) {
188 if data.is_empty() {
189 return (0.0, 1.0);
190 }
191 let min = data.iter().fold(f64::MAX, |a, &b| a.min(b));
192 let max = data.iter().fold(f64::MIN, |a, &b| a.max(b));
193 if (max - min).abs() < f64::EPSILON {
194 (min - 0.5, max + 0.5)
195 } else {
196 (min, max)
197 }
198 }
199
200 fn normalize(&self, value: f64) -> f64 {
201 let range = self.max - self.min;
202 if range.abs() < f64::EPSILON {
203 0.5
204 } else {
205 ((value - self.min) / range).clamp(0.0, 1.0)
206 }
207 }
208}
209
210impl Brick for Sparkline {
211 fn brick_name(&self) -> &'static str {
212 "sparkline"
213 }
214
215 fn assertions(&self) -> &[BrickAssertion] {
216 static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
217 ASSERTIONS
218 }
219
220 fn budget(&self) -> BrickBudget {
221 BrickBudget::uniform(16)
222 }
223
224 fn verify(&self) -> BrickVerification {
225 BrickVerification {
226 passed: self.assertions().to_vec(),
227 failed: vec![],
228 verification_time: Duration::from_micros(5),
229 }
230 }
231
232 fn to_html(&self) -> String {
233 String::new()
234 }
235
236 fn to_css(&self) -> String {
237 String::new()
238 }
239}
240
241impl Widget for Sparkline {
242 fn type_id(&self) -> TypeId {
243 TypeId::of::<Self>()
244 }
245
246 fn measure(&self, constraints: Constraints) -> Size {
247 let width = (self.data.len() as f32 + if self.show_trend { 2.0 } else { 0.0 })
248 .min(constraints.max_width)
249 .max(1.0);
250 constraints.constrain(Size::new(width, 1.0))
251 }
252
253 fn layout(&mut self, bounds: Rect) -> LayoutResult {
254 self.bounds = bounds;
255 LayoutResult {
256 size: Size::new(bounds.width, bounds.height.max(1.0)),
257 }
258 }
259
260 fn paint(&self, canvas: &mut dyn Canvas) {
261 if self.data.is_empty() || self.bounds.width < 1.0 {
262 return;
263 }
264
265 let available_width = if self.show_trend {
266 (self.bounds.width as usize).saturating_sub(2)
267 } else {
268 self.bounds.width as usize
269 };
270
271 if available_width == 0 {
272 return;
273 }
274
275 let mut spark = String::with_capacity(available_width);
277
278 for i in 0..available_width.min(self.data.len()) {
279 let idx = (i * self.data.len()) / available_width;
280 let value = self.data.get(idx).copied().unwrap_or(0.0);
281 let norm = self.normalize(value);
282 let char_idx = ((norm * 7.0).round() as usize).min(7);
283 spark.push(SPARK_CHARS[char_idx]);
284 }
285
286 let style = TextStyle {
287 color: self.color,
288 ..Default::default()
289 };
290 canvas.draw_text(&spark, Point::new(self.bounds.x, self.bounds.y), &style);
291
292 if self.show_trend {
294 let trend = self.trend();
295 let trend_style = TextStyle {
296 color: trend.color(),
297 ..Default::default()
298 };
299 canvas.draw_text(
300 &format!(" {}", trend.arrow()),
301 Point::new(self.bounds.x + available_width as f32, self.bounds.y),
302 &trend_style,
303 );
304 }
305 }
306
307 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
308 None
309 }
310
311 fn children(&self) -> &[Box<dyn Widget>] {
312 &[]
313 }
314
315 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
316 &mut []
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 struct MockCanvas {
325 texts: Vec<(String, Point)>,
326 }
327
328 impl MockCanvas {
329 fn new() -> Self {
330 Self { texts: vec![] }
331 }
332 }
333
334 impl Canvas for MockCanvas {
335 fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
336 fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
337 fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
338 self.texts.push((text.to_string(), position));
339 }
340 fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
341 fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
342 fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
343 fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
344 fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
345 fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
346 fn push_clip(&mut self, _rect: Rect) {}
347 fn pop_clip(&mut self) {}
348 fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
349 fn pop_transform(&mut self) {}
350 }
351
352 #[test]
353 fn test_sparkline_creation() {
354 let spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
355 assert_eq!(spark.data.len(), 5);
356 }
357
358 #[test]
359 fn test_sparkline_assertions() {
360 let spark = Sparkline::new(vec![1.0]);
361 assert!(!spark.assertions().is_empty());
362 }
363
364 #[test]
365 fn test_sparkline_verify() {
366 let spark = Sparkline::new(vec![1.0, 2.0]);
367 assert!(spark.verify().is_valid());
368 }
369
370 #[test]
371 fn test_sparkline_with_color() {
372 let spark = Sparkline::new(vec![1.0]).with_color(Color::RED);
373 assert_eq!(spark.color, Color::RED);
374 }
375
376 #[test]
377 fn test_sparkline_with_range() {
378 let spark = Sparkline::new(vec![1.0]).with_range(0.0, 100.0);
379 assert_eq!(spark.min, 0.0);
380 assert_eq!(spark.max, 100.0);
381 }
382
383 #[test]
384 fn test_sparkline_with_trend() {
385 let spark = Sparkline::new(vec![1.0]).with_trend(true);
386 assert!(spark.show_trend);
387 }
388
389 #[test]
390 fn test_sparkline_trend_up() {
391 let spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
392 assert_eq!(spark.trend(), TrendDirection::Up);
393 }
394
395 #[test]
396 fn test_sparkline_trend_down() {
397 let spark = Sparkline::new(vec![8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]);
398 assert_eq!(spark.trend(), TrendDirection::Down);
399 }
400
401 #[test]
402 fn test_sparkline_trend_flat() {
403 let spark = Sparkline::new(vec![5.0, 5.0, 5.0, 5.0, 5.0]);
404 assert_eq!(spark.trend(), TrendDirection::Flat);
405 }
406
407 #[test]
408 fn test_sparkline_paint() {
409 let mut spark = Sparkline::new(vec![0.0, 0.5, 1.0]);
410 spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
411 let mut canvas = MockCanvas::new();
412 spark.paint(&mut canvas);
413 assert!(!canvas.texts.is_empty());
414 }
415
416 #[test]
417 #[allow(clippy::len_zero)]
418 fn test_sparkline_paint_with_trend() {
419 let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_trend(true);
420 spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
421 let mut canvas = MockCanvas::new();
422 spark.paint(&mut canvas);
423 assert!(canvas.texts.len() >= 1);
424 }
425
426 #[test]
427 fn test_sparkline_empty() {
428 let mut spark = Sparkline::new(vec![]);
429 spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
430 let mut canvas = MockCanvas::new();
431 spark.paint(&mut canvas);
432 assert!(canvas.texts.is_empty());
433 }
434
435 #[test]
436 fn test_sparkline_measure() {
437 let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
438 let size = spark.measure(Constraints::loose(Size::new(100.0, 10.0)));
439 assert!(size.width >= 3.0);
440 assert_eq!(size.height, 1.0);
441 }
442
443 #[test]
444 fn test_sparkline_layout() {
445 let mut spark = Sparkline::new(vec![1.0, 2.0]);
446 let bounds = Rect::new(5.0, 10.0, 20.0, 1.0);
447 let result = spark.layout(bounds);
448 assert_eq!(result.size.width, 20.0);
449 assert_eq!(spark.bounds, bounds);
450 }
451
452 #[test]
453 fn test_trend_direction_arrow() {
454 assert_eq!(TrendDirection::Up.arrow(), '↑');
455 assert_eq!(TrendDirection::Down.arrow(), '↓');
456 assert_eq!(TrendDirection::Flat.arrow(), '→');
457 }
458
459 #[test]
460 fn test_trend_direction_color() {
461 let _ = TrendDirection::Up.color();
462 let _ = TrendDirection::Down.color();
463 let _ = TrendDirection::Flat.color();
464 }
465
466 #[test]
467 fn test_sparkline_set_data() {
468 let mut spark = Sparkline::new(vec![1.0]);
469 spark.set_data(vec![1.0, 2.0, 3.0, 4.0]);
470 assert_eq!(spark.data.len(), 4);
471 }
472
473 #[test]
474 fn test_sparkline_brick_name() {
475 let spark = Sparkline::new(vec![]);
476 assert_eq!(spark.brick_name(), "sparkline");
477 }
478
479 #[test]
480 fn test_sparkline_budget() {
481 let spark = Sparkline::new(vec![]);
482 let budget = spark.budget();
483 assert!(budget.paint_ms > 0);
484 }
485
486 #[test]
487 fn test_sparkline_type_id() {
488 let spark = Sparkline::new(vec![]);
489 assert_eq!(Widget::type_id(&spark), TypeId::of::<Sparkline>());
490 }
491
492 #[test]
493 fn test_sparkline_children() {
494 let spark = Sparkline::new(vec![]);
495 assert!(spark.children().is_empty());
496 }
497
498 #[test]
499 fn test_sparkline_children_mut() {
500 let mut spark = Sparkline::new(vec![]);
501 assert!(spark.children_mut().is_empty());
502 }
503
504 #[test]
505 fn test_sparkline_event() {
506 let mut spark = Sparkline::new(vec![]);
507 let event = Event::key_down(presentar_core::Key::Enter);
508 assert!(spark.event(&event).is_none());
509 }
510
511 #[test]
512 fn test_sparkline_default() {
513 let spark = Sparkline::default();
514 assert!(spark.data.is_empty());
515 }
516
517 #[test]
518 fn test_sparkline_to_html() {
519 let spark = Sparkline::new(vec![]);
520 assert!(spark.to_html().is_empty());
521 }
522
523 #[test]
524 fn test_sparkline_to_css() {
525 let spark = Sparkline::new(vec![]);
526 assert!(spark.to_css().is_empty());
527 }
528
529 #[test]
530 fn test_sparkline_trend_single_value() {
531 let spark = Sparkline::new(vec![5.0]);
533 assert_eq!(spark.trend(), TrendDirection::Flat);
534 }
535
536 #[test]
537 fn test_sparkline_trend_two_values() {
538 let spark = Sparkline::new(vec![1.0, 2.0]);
540 assert_eq!(spark.trend(), TrendDirection::Flat);
542 }
543
544 #[test]
545 fn test_sparkline_trend_three_values() {
546 let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
550 assert_eq!(spark.trend(), TrendDirection::Flat);
551 }
552
553 #[test]
554 fn test_sparkline_normalize_zero_range() {
555 let spark = Sparkline::new(vec![5.0, 5.0, 5.0]);
557 let normalized = spark.normalize(5.0);
559 assert!((normalized - 0.5).abs() < f64::EPSILON);
560 }
561
562 #[test]
563 fn test_sparkline_paint_zero_available_width() {
564 let mut spark = Sparkline::new(vec![1.0, 2.0]).with_trend(true);
566 spark.bounds = Rect::new(0.0, 0.0, 2.0, 1.0); let mut canvas = MockCanvas::new();
568 spark.paint(&mut canvas);
569 }
571
572 #[test]
573 fn test_sparkline_paint_narrow_width() {
574 let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0]).with_trend(true);
576 spark.bounds = Rect::new(0.0, 0.0, 1.0, 1.0);
577 let mut canvas = MockCanvas::new();
578 spark.paint(&mut canvas);
579 }
581
582 #[test]
583 fn test_sparkline_with_y_axis() {
584 let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(true);
585 assert!(spark.show_y_axis);
586 }
587
588 #[test]
589 fn test_sparkline_with_y_axis_false() {
590 let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(false);
591 assert!(!spark.show_y_axis);
592 }
593
594 #[test]
595 #[allow(clippy::literal_string_with_formatting_args)]
596 fn test_sparkline_with_y_format() {
597 let spark = Sparkline::new(vec![1.0, 2.0]).with_y_format("{:.0}%");
598 assert!(spark.show_y_axis);
599 assert_eq!(spark.y_format, Some("{:.0}%".to_string()));
600 }
601
602 #[test]
603 fn test_sparkline_y_axis_width_no_axis() {
604 let spark = Sparkline::new(vec![1.0, 2.0]);
605 assert_eq!(spark.y_axis_width(), 0);
606 }
607
608 #[test]
609 fn test_sparkline_y_axis_width_with_axis() {
610 let spark = Sparkline::new(vec![1.0, 100.0]).with_y_axis(true);
611 let width = spark.y_axis_width();
612 assert!(width > 0);
613 }
614
615 #[test]
616 #[allow(clippy::literal_string_with_formatting_args)]
617 fn test_sparkline_y_axis_width_with_format() {
618 let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.0}%");
619 let width = spark.y_axis_width();
620 assert!(width > 0);
621 }
622
623 #[test]
624 #[allow(clippy::literal_string_with_formatting_args)]
625 fn test_sparkline_y_axis_width_with_format_decimal() {
626 let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.1}ms");
627 let width = spark.y_axis_width();
628 assert!(width > 0);
629 }
630}