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 fn test_sparkline_paint_with_trend() {
418 let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_trend(true);
419 spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
420 let mut canvas = MockCanvas::new();
421 spark.paint(&mut canvas);
422 assert!(canvas.texts.len() >= 1);
423 }
424
425 #[test]
426 fn test_sparkline_empty() {
427 let mut spark = Sparkline::new(vec![]);
428 spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
429 let mut canvas = MockCanvas::new();
430 spark.paint(&mut canvas);
431 assert!(canvas.texts.is_empty());
432 }
433
434 #[test]
435 fn test_sparkline_measure() {
436 let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
437 let size = spark.measure(Constraints::loose(Size::new(100.0, 10.0)));
438 assert!(size.width >= 3.0);
439 assert_eq!(size.height, 1.0);
440 }
441
442 #[test]
443 fn test_sparkline_layout() {
444 let mut spark = Sparkline::new(vec![1.0, 2.0]);
445 let bounds = Rect::new(5.0, 10.0, 20.0, 1.0);
446 let result = spark.layout(bounds);
447 assert_eq!(result.size.width, 20.0);
448 assert_eq!(spark.bounds, bounds);
449 }
450
451 #[test]
452 fn test_trend_direction_arrow() {
453 assert_eq!(TrendDirection::Up.arrow(), '↑');
454 assert_eq!(TrendDirection::Down.arrow(), '↓');
455 assert_eq!(TrendDirection::Flat.arrow(), '→');
456 }
457
458 #[test]
459 fn test_trend_direction_color() {
460 let _ = TrendDirection::Up.color();
461 let _ = TrendDirection::Down.color();
462 let _ = TrendDirection::Flat.color();
463 }
464
465 #[test]
466 fn test_sparkline_set_data() {
467 let mut spark = Sparkline::new(vec![1.0]);
468 spark.set_data(vec![1.0, 2.0, 3.0, 4.0]);
469 assert_eq!(spark.data.len(), 4);
470 }
471
472 #[test]
473 fn test_sparkline_brick_name() {
474 let spark = Sparkline::new(vec![]);
475 assert_eq!(spark.brick_name(), "sparkline");
476 }
477
478 #[test]
479 fn test_sparkline_budget() {
480 let spark = Sparkline::new(vec![]);
481 let budget = spark.budget();
482 assert!(budget.paint_ms > 0);
483 }
484
485 #[test]
486 fn test_sparkline_type_id() {
487 let spark = Sparkline::new(vec![]);
488 assert_eq!(Widget::type_id(&spark), TypeId::of::<Sparkline>());
489 }
490
491 #[test]
492 fn test_sparkline_children() {
493 let spark = Sparkline::new(vec![]);
494 assert!(spark.children().is_empty());
495 }
496
497 #[test]
498 fn test_sparkline_children_mut() {
499 let mut spark = Sparkline::new(vec![]);
500 assert!(spark.children_mut().is_empty());
501 }
502
503 #[test]
504 fn test_sparkline_event() {
505 let mut spark = Sparkline::new(vec![]);
506 let event = Event::KeyDown {
507 key: presentar_core::Key::Enter,
508 };
509 assert!(spark.event(&event).is_none());
510 }
511
512 #[test]
513 fn test_sparkline_default() {
514 let spark = Sparkline::default();
515 assert!(spark.data.is_empty());
516 }
517
518 #[test]
519 fn test_sparkline_to_html() {
520 let spark = Sparkline::new(vec![]);
521 assert!(spark.to_html().is_empty());
522 }
523
524 #[test]
525 fn test_sparkline_to_css() {
526 let spark = Sparkline::new(vec![]);
527 assert!(spark.to_css().is_empty());
528 }
529
530 #[test]
531 fn test_sparkline_trend_single_value() {
532 let spark = Sparkline::new(vec![5.0]);
534 assert_eq!(spark.trend(), TrendDirection::Flat);
535 }
536
537 #[test]
538 fn test_sparkline_trend_two_values() {
539 let spark = Sparkline::new(vec![1.0, 2.0]);
541 assert_eq!(spark.trend(), TrendDirection::Flat);
543 }
544
545 #[test]
546 fn test_sparkline_trend_three_values() {
547 let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
551 assert_eq!(spark.trend(), TrendDirection::Flat);
552 }
553
554 #[test]
555 fn test_sparkline_normalize_zero_range() {
556 let spark = Sparkline::new(vec![5.0, 5.0, 5.0]);
558 let normalized = spark.normalize(5.0);
560 assert!((normalized - 0.5).abs() < f64::EPSILON);
561 }
562
563 #[test]
564 fn test_sparkline_paint_zero_available_width() {
565 let mut spark = Sparkline::new(vec![1.0, 2.0]).with_trend(true);
567 spark.bounds = Rect::new(0.0, 0.0, 2.0, 1.0); let mut canvas = MockCanvas::new();
569 spark.paint(&mut canvas);
570 }
572
573 #[test]
574 fn test_sparkline_paint_narrow_width() {
575 let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0]).with_trend(true);
577 spark.bounds = Rect::new(0.0, 0.0, 1.0, 1.0);
578 let mut canvas = MockCanvas::new();
579 spark.paint(&mut canvas);
580 }
582
583 #[test]
584 fn test_sparkline_with_y_axis() {
585 let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(true);
586 assert!(spark.show_y_axis);
587 }
588
589 #[test]
590 fn test_sparkline_with_y_axis_false() {
591 let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(false);
592 assert!(!spark.show_y_axis);
593 }
594
595 #[test]
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 fn test_sparkline_y_axis_width_with_format() {
617 let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.0}%");
618 let width = spark.y_axis_width();
619 assert!(width > 0);
620 }
621
622 #[test]
623 fn test_sparkline_y_axis_width_with_format_decimal() {
624 let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.1}ms");
625 let width = spark.y_axis_width();
626 assert!(width > 0);
627 }
628}