Skip to main content

presentar_terminal/widgets/
horizon.rs

1//! `HorizonGraph` widget for high-density time-series visualization.
2//!
3//! Implements horizon charts as described by Heer et al. (2009).
4//! Allows displaying 64+ CPU cores in minimal vertical space by "folding"
5//! bands of value into overlapping colored layers.
6//!
7//! Citation: Heer, J., Kong, N., & Agrawala, M. (2009). "Sizing the Horizon"
8//!
9//! VS-001: CPU Cores use Heatmap/Horizon
10
11use presentar_core::{
12    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
13    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
14};
15use std::any::Any;
16use std::time::Duration;
17
18/// Color scheme for horizon bands.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum HorizonScheme {
21    /// Blue-based (cool) for normal metrics
22    #[default]
23    Blues,
24    /// Red-based (warm) for temperature/critical
25    Reds,
26    /// Green-based for memory/capacity
27    Greens,
28    /// Purple for GPU metrics
29    Purples,
30}
31
32impl HorizonScheme {
33    /// Get colors for each band (from light to dark).
34    fn band_colors(self, bands: u8) -> Vec<Color> {
35        let base = match self {
36            Self::Blues => (0.2, 0.4, 0.9),
37            Self::Reds => (0.9, 0.3, 0.2),
38            Self::Greens => (0.2, 0.8, 0.3),
39            Self::Purples => (0.7, 0.3, 0.9),
40        };
41
42        (0..bands)
43            .map(|i| {
44                let factor = 0.4 + 0.6 * (i as f32 / bands as f32);
45                Color::new(base.0 * factor, base.1 * factor, base.2 * factor, 1.0)
46            })
47            .collect()
48    }
49}
50
51/// High-density time-series visualization using horizon chart technique.
52///
53/// Horizon charts "fold" values into overlapping bands, allowing dense
54/// visualization of many data series in limited vertical space.
55///
56/// # Example
57/// ```
58/// use presentar_terminal::HorizonGraph;
59///
60/// let graph = HorizonGraph::new(vec![0.2, 0.5, 0.8, 0.3, 0.6])
61///     .with_bands(3)
62///     .with_label("CPU0");
63/// ```
64#[derive(Debug, Clone)]
65pub struct HorizonGraph {
66    /// Data values (0.0-1.0 normalized).
67    data: Vec<f64>,
68    /// Number of horizon bands (typically 2-4).
69    bands: u8,
70    /// Color scheme.
71    scheme: HorizonScheme,
72    /// Optional label.
73    label: Option<String>,
74    /// Cached bounds.
75    bounds: Rect,
76}
77
78impl Default for HorizonGraph {
79    fn default() -> Self {
80        Self::new(Vec::new())
81    }
82}
83
84impl HorizonGraph {
85    /// Create a new horizon graph with data.
86    #[must_use]
87    pub fn new(data: Vec<f64>) -> Self {
88        Self {
89            data,
90            bands: 3,
91            scheme: HorizonScheme::default(),
92            label: None,
93            bounds: Rect::default(),
94        }
95    }
96
97    /// Set the number of bands (2-4 recommended).
98    #[must_use]
99    pub fn with_bands(mut self, bands: u8) -> Self {
100        debug_assert!((1..=6).contains(&bands), "bands must be 1-6");
101        self.bands = bands.clamp(1, 6);
102        self
103    }
104
105    /// Set the color scheme.
106    #[must_use]
107    pub fn with_scheme(mut self, scheme: HorizonScheme) -> Self {
108        self.scheme = scheme;
109        self
110    }
111
112    /// Set a label.
113    #[must_use]
114    pub fn with_label(mut self, label: impl Into<String>) -> Self {
115        self.label = Some(label.into());
116        self
117    }
118
119    /// Update data in place.
120    pub fn set_data(&mut self, data: Vec<f64>) {
121        self.data = data;
122    }
123
124    /// Compute which band a value falls into.
125    fn value_to_band(&self, value: f64) -> (u8, f64) {
126        let clamped = value.clamp(0.0, 1.0);
127        let band_height = 1.0 / self.bands as f64;
128        let band = (clamped / band_height).floor() as u8;
129        let within_band = (clamped % band_height) / band_height;
130        (band.min(self.bands - 1), within_band)
131    }
132
133    /// Render using block characters for bands.
134    fn render_horizon(&self, canvas: &mut dyn Canvas) {
135        if self.data.is_empty() || self.bounds.width < 1.0 || self.bounds.height < 1.0 {
136            return;
137        }
138
139        let colors = self.scheme.band_colors(self.bands);
140        let width = self.bounds.width as usize;
141        let height = self.bounds.height as usize;
142
143        // Sample data to fit width
144        let data_len = self.data.len();
145        let step = if data_len > width {
146            data_len as f64 / width as f64
147        } else {
148            1.0
149        };
150
151        // Block characters for vertical fill
152        let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
153
154        for x in 0..width.min(data_len) {
155            let idx = (x as f64 * step) as usize;
156            if idx >= data_len {
157                break;
158            }
159
160            let value = self.data[idx];
161            let (band, intensity) = self.value_to_band(value);
162
163            // Choose block character based on intensity
164            let block_idx = (intensity * 7.0) as usize;
165            let block = blocks[block_idx.min(7)];
166
167            // Get color for this band
168            let color = if (band as usize) < colors.len() {
169                colors[band as usize]
170            } else {
171                colors[colors.len() - 1]
172            };
173
174            // Draw the block
175            let style = TextStyle {
176                color,
177                ..Default::default()
178            };
179            canvas.draw_text(
180                &block.to_string(),
181                Point::new(
182                    self.bounds.x + x as f32,
183                    self.bounds.y + height as f32 - 1.0,
184                ),
185                &style,
186            );
187
188            // For higher bands, draw additional layers above
189            for b in 0..band {
190                let offset = 2 + b as usize;
191                if height > offset {
192                    let layer_color = if (b as usize) < colors.len() {
193                        colors[b as usize]
194                    } else {
195                        colors[0]
196                    };
197                    let layer_style = TextStyle {
198                        color: layer_color,
199                        ..Default::default()
200                    };
201                    canvas.draw_text(
202                        "█",
203                        Point::new(
204                            self.bounds.x + x as f32,
205                            self.bounds.y + (height - offset) as f32,
206                        ),
207                        &layer_style,
208                    );
209                }
210            }
211        }
212
213        // Draw label if present
214        if let Some(ref label) = self.label {
215            let style = TextStyle {
216                color: Color::WHITE,
217                ..Default::default()
218            };
219            canvas.draw_text(label, Point::new(self.bounds.x, self.bounds.y), &style);
220        }
221    }
222}
223
224impl Widget for HorizonGraph {
225    fn type_id(&self) -> TypeId {
226        TypeId::of::<Self>()
227    }
228
229    fn measure(&self, constraints: Constraints) -> Size {
230        let width = constraints.max_width.min(self.data.len() as f32);
231        let height = constraints.max_height.min(self.bands as f32 + 1.0);
232        Size::new(width, height)
233    }
234
235    fn layout(&mut self, bounds: Rect) -> LayoutResult {
236        self.bounds = bounds;
237        LayoutResult {
238            size: Size::new(bounds.width, bounds.height),
239        }
240    }
241
242    fn paint(&self, canvas: &mut dyn Canvas) {
243        self.render_horizon(canvas);
244    }
245
246    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
247        None
248    }
249
250    fn children(&self) -> &[Box<dyn Widget>] {
251        &[]
252    }
253
254    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
255        &mut []
256    }
257}
258
259impl Brick for HorizonGraph {
260    fn brick_name(&self) -> &'static str {
261        "horizon_graph"
262    }
263
264    fn assertions(&self) -> &[BrickAssertion] {
265        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
266        ASSERTIONS
267    }
268
269    fn budget(&self) -> BrickBudget {
270        BrickBudget::uniform(16)
271    }
272
273    fn verify(&self) -> BrickVerification {
274        BrickVerification {
275            passed: self.assertions().to_vec(),
276            failed: vec![],
277            verification_time: Duration::from_micros(5),
278        }
279    }
280
281    fn to_html(&self) -> String {
282        String::new()
283    }
284
285    fn to_css(&self) -> String {
286        String::new()
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_horizon_graph_default() {
296        let graph = HorizonGraph::default();
297        assert!(graph.data.is_empty());
298        assert_eq!(graph.bands, 3);
299    }
300
301    #[test]
302    fn test_horizon_graph_with_data() {
303        let graph = HorizonGraph::new(vec![0.1, 0.5, 0.9])
304            .with_bands(4)
305            .with_label("CPU0");
306        assert_eq!(graph.data.len(), 3);
307        assert_eq!(graph.bands, 4);
308        assert_eq!(graph.label, Some("CPU0".to_string()));
309    }
310
311    #[test]
312    fn test_value_to_band() {
313        let graph = HorizonGraph::new(vec![]).with_bands(3);
314
315        let (band, _) = graph.value_to_band(0.1);
316        assert_eq!(band, 0);
317
318        let (band, _) = graph.value_to_band(0.5);
319        assert_eq!(band, 1);
320
321        let (band, _) = graph.value_to_band(0.9);
322        assert_eq!(band, 2);
323    }
324
325    #[test]
326    fn test_band_colors() {
327        let colors = HorizonScheme::Blues.band_colors(3);
328        assert_eq!(colors.len(), 3);
329    }
330
331    #[test]
332    fn test_horizon_implements_widget() {
333        let mut graph = HorizonGraph::new(vec![0.5, 0.6, 0.7]);
334        let size = graph.measure(Constraints {
335            min_width: 0.0,
336            min_height: 0.0,
337            max_width: 100.0,
338            max_height: 10.0,
339        });
340        assert!(size.width > 0.0);
341        assert!(size.height > 0.0);
342    }
343
344    #[test]
345    fn test_horizon_implements_brick() {
346        let graph = HorizonGraph::new(vec![0.5]);
347        assert_eq!(graph.brick_name(), "horizon_graph");
348        assert!(graph.verify().is_valid());
349    }
350
351    #[test]
352    fn test_horizon_event() {
353        let mut graph = HorizonGraph::new(vec![]);
354        let event = Event::KeyDown {
355            key: presentar_core::Key::Enter,
356        };
357        assert!(graph.event(&event).is_none());
358    }
359
360    #[test]
361    fn test_horizon_children() {
362        let graph = HorizonGraph::new(vec![]);
363        assert!(graph.children().is_empty());
364    }
365
366    #[test]
367    fn test_horizon_children_mut() {
368        let mut graph = HorizonGraph::new(vec![]);
369        assert!(graph.children_mut().is_empty());
370    }
371
372    #[test]
373    fn test_horizon_to_html() {
374        let graph = HorizonGraph::new(vec![]);
375        assert!(graph.to_html().is_empty());
376    }
377
378    #[test]
379    fn test_horizon_to_css() {
380        let graph = HorizonGraph::new(vec![]);
381        assert!(graph.to_css().is_empty());
382    }
383
384    #[test]
385    fn test_horizon_budget() {
386        let graph = HorizonGraph::new(vec![]);
387        let budget = graph.budget();
388        assert!(budget.paint_ms > 0);
389    }
390
391    #[test]
392    fn test_horizon_assertions() {
393        let graph = HorizonGraph::new(vec![]);
394        assert!(!graph.assertions().is_empty());
395    }
396
397    #[test]
398    fn test_horizon_type_id() {
399        let graph = HorizonGraph::new(vec![]);
400        assert_eq!(Widget::type_id(&graph), TypeId::of::<HorizonGraph>());
401    }
402
403    #[test]
404    fn test_horizon_layout_and_paint() {
405        use crate::direct::{CellBuffer, DirectTerminalCanvas};
406
407        let mut graph = HorizonGraph::new(vec![0.1, 0.3, 0.5, 0.7, 0.9])
408            .with_bands(4)
409            .with_label("Test")
410            .with_scheme(HorizonScheme::Blues);
411
412        let bounds = presentar_core::Rect::new(0.0, 0.0, 40.0, 3.0);
413        graph.layout(bounds);
414
415        let mut buffer = CellBuffer::new(40, 3);
416        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
417        graph.paint(&mut canvas);
418        // Verify it doesn't panic
419    }
420
421    #[test]
422    fn test_horizon_all_schemes() {
423        use crate::direct::{CellBuffer, DirectTerminalCanvas};
424
425        for scheme in [
426            HorizonScheme::Blues,
427            HorizonScheme::Greens,
428            HorizonScheme::Reds,
429            HorizonScheme::Purples,
430        ] {
431            let mut graph = HorizonGraph::new(vec![0.2, 0.5, 0.8]).with_scheme(scheme);
432            let bounds = presentar_core::Rect::new(0.0, 0.0, 20.0, 2.0);
433            graph.layout(bounds);
434
435            let mut buffer = CellBuffer::new(20, 2);
436            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
437            graph.paint(&mut canvas);
438        }
439    }
440
441    #[test]
442    fn test_horizon_different_band_counts() {
443        use crate::direct::{CellBuffer, DirectTerminalCanvas};
444
445        for bands in [2, 3, 4, 5, 6] {
446            let mut graph = HorizonGraph::new(vec![0.1, 0.5, 0.9]).with_bands(bands);
447            let bounds = presentar_core::Rect::new(0.0, 0.0, 30.0, 2.0);
448            graph.layout(bounds);
449
450            let mut buffer = CellBuffer::new(30, 2);
451            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
452            graph.paint(&mut canvas);
453        }
454    }
455
456    #[test]
457    fn test_horizon_empty_data() {
458        use crate::direct::{CellBuffer, DirectTerminalCanvas};
459
460        let mut graph = HorizonGraph::new(vec![]);
461        let bounds = presentar_core::Rect::new(0.0, 0.0, 20.0, 2.0);
462        graph.layout(bounds);
463
464        let mut buffer = CellBuffer::new(20, 2);
465        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
466        graph.paint(&mut canvas);
467    }
468
469    #[test]
470    fn test_horizon_edge_values() {
471        let graph = HorizonGraph::new(vec![]).with_bands(4);
472
473        // Edge cases
474        let (band, _) = graph.value_to_band(0.0);
475        assert_eq!(band, 0);
476
477        let (band, _) = graph.value_to_band(1.0);
478        assert_eq!(band, 3); // Last band for max value
479
480        let (band, _) = graph.value_to_band(-0.1);
481        assert_eq!(band, 0); // Clamped to min
482
483        let (band, _) = graph.value_to_band(1.5);
484        assert_eq!(band, 3); // Clamped to max
485    }
486}