Skip to main content

entrenar/train/tui/
sparkline.rs

1//! Sparkline - Unicode Visualization (ENT-057)
2//!
3//! Unicode sparklines for inline metric visualization.
4//! Reference: Tufte, E. R. (2006). *Beautiful Evidence*. Graphics Press.
5
6/// Unicode sparkline characters for inline metric visualization.
7pub const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
8
9/// Generate a sparkline string from a slice of values.
10///
11/// Uses Unicode block elements to create a compact inline chart.
12///
13/// # Arguments
14///
15/// * `values` - The values to visualize
16/// * `width` - Maximum width (values will be subsampled if needed)
17///
18/// # Returns
19///
20/// A string of Unicode block characters representing the values.
21pub fn sparkline(values: &[f32], width: usize) -> String {
22    if values.is_empty() || width == 0 {
23        return String::new();
24    }
25
26    // Subsample if needed
27    let values: Vec<f32> = if values.len() > width {
28        let step = values.len() as f32 / width as f32;
29        (0..width)
30            .map(|i| {
31                let idx = (i as f32 * step) as usize;
32                values[idx.min(values.len() - 1)]
33            })
34            .collect()
35    } else {
36        values.to_vec()
37    };
38
39    // Find extent
40    let min = values.iter().copied().fold(f32::INFINITY, f32::min);
41    let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
42    let range = max - min;
43
44    // Handle constant values
45    if range < f32::EPSILON {
46        return SPARK_CHARS[4].to_string().repeat(values.len());
47    }
48
49    // Map to sparkline characters
50    values
51        .iter()
52        .map(|v| {
53            let normalized = (v - min) / range;
54            let idx = (normalized * 7.0).round() as usize;
55            SPARK_CHARS[idx.min(7)]
56        })
57        .collect()
58}
59
60/// Generate a sparkline with custom range.
61pub fn sparkline_range(values: &[f32], width: usize, min: f32, max: f32) -> String {
62    if values.is_empty() || width == 0 {
63        return String::new();
64    }
65
66    let range = max - min;
67    if range < f32::EPSILON {
68        return SPARK_CHARS[4].to_string().repeat(values.len().min(width));
69    }
70
71    let values: Vec<f32> = if values.len() > width {
72        let step = values.len() as f32 / width as f32;
73        (0..width).map(|i| values[(i as f32 * step) as usize]).collect()
74    } else {
75        values.to_vec()
76    };
77
78    values
79        .iter()
80        .map(|v| {
81            let clamped = v.clamp(min, max);
82            let normalized = (clamped - min) / range;
83            let idx = (normalized * 7.0).round() as usize;
84            SPARK_CHARS[idx.min(7)]
85        })
86        .collect()
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn test_sparkline_empty() {
95        assert_eq!(sparkline(&[], 10), "");
96    }
97
98    #[test]
99    fn test_sparkline_zero_width() {
100        assert_eq!(sparkline(&[1.0, 2.0, 3.0], 0), "");
101    }
102
103    #[test]
104    fn test_sparkline_constant() {
105        let result = sparkline(&[5.0, 5.0, 5.0, 5.0], 10);
106        assert!(result.chars().all(|c| c == SPARK_CHARS[4]));
107    }
108
109    #[test]
110    fn test_sparkline_ascending() {
111        let values: Vec<f32> = (0..8).map(|i| i as f32).collect();
112        let result = sparkline(&values, 8);
113        // First should be low, last should be high
114        let chars: Vec<char> = result.chars().collect();
115        assert_eq!(chars[0], SPARK_CHARS[0]);
116        assert_eq!(chars[7], SPARK_CHARS[7]);
117    }
118
119    #[test]
120    fn test_sparkline_descending() {
121        let values: Vec<f32> = (0..8).rev().map(|i| i as f32).collect();
122        let result = sparkline(&values, 8);
123        let chars: Vec<char> = result.chars().collect();
124        assert_eq!(chars[0], SPARK_CHARS[7]);
125        assert_eq!(chars[7], SPARK_CHARS[0]);
126    }
127
128    #[test]
129    fn test_sparkline_subsampling() {
130        let values: Vec<f32> = (0..100).map(|i| i as f32).collect();
131        let result = sparkline(&values, 10);
132        assert_eq!(result.chars().count(), 10);
133    }
134
135    #[test]
136    fn test_sparkline_range_empty() {
137        assert_eq!(sparkline_range(&[], 10, 0.0, 1.0), "");
138    }
139
140    #[test]
141    fn test_sparkline_range_clamping() {
142        let values = vec![-1.0, 0.0, 0.5, 1.0, 2.0];
143        let result = sparkline_range(&values, 5, 0.0, 1.0);
144        let chars: Vec<char> = result.chars().collect();
145        // -1.0 clamped to 0.0 -> SPARK_CHARS[0]
146        assert_eq!(chars[0], SPARK_CHARS[0]);
147        // 2.0 clamped to 1.0 -> SPARK_CHARS[7]
148        assert_eq!(chars[4], SPARK_CHARS[7]);
149    }
150
151    #[test]
152    fn test_sparkline_range_zero_width() {
153        assert_eq!(sparkline_range(&[1.0, 2.0], 0, 0.0, 1.0), "");
154    }
155
156    #[test]
157    fn test_sparkline_range_constant_range() {
158        let values = vec![5.0, 5.0, 5.0];
159        let result = sparkline_range(&values, 5, 5.0, 5.0);
160        // Constant range should produce middle sparkline char
161        assert!(result.chars().all(|c| c == SPARK_CHARS[4]));
162    }
163
164    #[test]
165    fn test_sparkline_range_subsampling() {
166        let values: Vec<f32> = (0..100).map(|i| i as f32).collect();
167        let result = sparkline_range(&values, 10, 0.0, 99.0);
168        assert_eq!(result.chars().count(), 10);
169    }
170
171    #[test]
172    fn test_spark_chars_length() {
173        assert_eq!(SPARK_CHARS.len(), 8);
174    }
175
176    #[test]
177    fn test_sparkline_single_value() {
178        let result = sparkline(&[5.0], 10);
179        // Single value with constant range
180        assert_eq!(result.chars().count(), 1);
181    }
182
183    #[test]
184    fn test_sparkline_range_middle_value() {
185        let result = sparkline_range(&[0.5], 1, 0.0, 1.0);
186        let chars: Vec<char> = result.chars().collect();
187        // 0.5 normalized = 0.5 * 7 = 3.5, rounds to 4
188        assert_eq!(chars[0], SPARK_CHARS[4]);
189    }
190}