Skip to main content

presentar_terminal/widgets/
heatmap.rs

1//! Heatmap widget for grid-based value visualization.
2//!
3//! Displays a 2D grid of values as colored cells. Supports multiple
4//! color palettes and optional value labels.
5
6use 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
13/// Color palette for heatmap rendering.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum HeatmapPalette {
16    /// Blue (cold) to Red (hot).
17    #[default]
18    BlueRed,
19    /// Viridis-like (purple to yellow).
20    Viridis,
21    /// Green (low) to Red (high).
22    GreenRed,
23    /// Grayscale.
24    Grayscale,
25    /// Single color intensity.
26    Mono(u8, u8, u8),
27}
28
29impl HeatmapPalette {
30    /// Get color for normalized value (0.0 to 1.0).
31    #[must_use]
32    pub fn color(&self, value: f64) -> Color {
33        let t = value.clamp(0.0, 1.0) as f32;
34        match self {
35            Self::BlueRed => {
36                if t < 0.5 {
37                    let s = t * 2.0;
38                    Color::new(s, s, 1.0, 1.0)
39                } else {
40                    let s = (t - 0.5) * 2.0;
41                    Color::new(1.0, 1.0 - s, 1.0 - s, 1.0)
42                }
43            }
44            Self::Viridis => {
45                let colors = [
46                    (0.27, 0.00, 0.33),
47                    (0.28, 0.14, 0.45),
48                    (0.26, 0.24, 0.53),
49                    (0.22, 0.34, 0.55),
50                    (0.18, 0.44, 0.56),
51                    (0.12, 0.56, 0.55),
52                    (0.20, 0.72, 0.47),
53                    (0.99, 0.91, 0.15),
54                ];
55                let idx = ((t * 7.0) as usize).min(6);
56                let frac = (t * 7.0) - idx as f32;
57                let (r1, g1, b1) = colors[idx];
58                let (r2, g2, b2) = colors[(idx + 1).min(7)];
59                Color::new(
60                    r1 + (r2 - r1) * frac,
61                    g1 + (g2 - g1) * frac,
62                    b1 + (b2 - b1) * frac,
63                    1.0,
64                )
65            }
66            Self::GreenRed => Color::new(t, 1.0 - t, 0.0, 1.0),
67            Self::Grayscale => Color::new(t, t, t, 1.0),
68            Self::Mono(r, g, b) => {
69                let r = (*r as f32 / 255.0) * t;
70                let g = (*g as f32 / 255.0) * t;
71                let b = (*b as f32 / 255.0) * t;
72                Color::new(r, g, b, 1.0)
73            }
74        }
75    }
76}
77
78/// A single heatmap cell.
79#[derive(Debug, Clone)]
80pub struct HeatmapCell {
81    /// Cell value (will be normalized).
82    pub value: f64,
83    /// Optional label to display.
84    pub label: Option<String>,
85}
86
87impl HeatmapCell {
88    /// Create a cell with a value.
89    #[must_use]
90    pub fn new(value: f64) -> Self {
91        Self { value, label: None }
92    }
93
94    /// Create a cell with value and label.
95    #[must_use]
96    pub fn with_label(value: f64, label: impl Into<String>) -> Self {
97        Self {
98            value,
99            label: Some(label.into()),
100        }
101    }
102}
103
104/// Heatmap widget for 2D grid visualization.
105#[derive(Debug, Clone)]
106pub struct Heatmap {
107    /// Grid data (row-major order).
108    data: Vec<Vec<HeatmapCell>>,
109    /// Row labels.
110    row_labels: Vec<String>,
111    /// Column labels.
112    col_labels: Vec<String>,
113    /// Color palette.
114    palette: HeatmapPalette,
115    /// Minimum value for normalization.
116    min: f64,
117    /// Maximum value for normalization.
118    max: f64,
119    /// Show cell values.
120    show_values: bool,
121    /// Cell width in characters.
122    cell_width: u16,
123    /// Cell height in characters.
124    cell_height: u16,
125    /// Cached bounds.
126    bounds: Rect,
127}
128
129impl Default for Heatmap {
130    fn default() -> Self {
131        Self::new(vec![])
132    }
133}
134
135impl Heatmap {
136    /// Create a new heatmap with data.
137    #[must_use]
138    pub fn new(data: Vec<Vec<HeatmapCell>>) -> Self {
139        let (min, max) = Self::compute_range(&data);
140        Self {
141            data,
142            row_labels: vec![],
143            col_labels: vec![],
144            palette: HeatmapPalette::default(),
145            min,
146            max,
147            show_values: false,
148            cell_width: 4,
149            cell_height: 1,
150            bounds: Rect::default(),
151        }
152    }
153
154    /// Create from a 2D array of f64 values.
155    #[must_use]
156    pub fn from_values(values: Vec<Vec<f64>>) -> Self {
157        let data: Vec<Vec<HeatmapCell>> = values
158            .into_iter()
159            .map(|row| row.into_iter().map(HeatmapCell::new).collect())
160            .collect();
161        Self::new(data)
162    }
163
164    /// Set row labels.
165    #[must_use]
166    pub fn with_row_labels(mut self, labels: Vec<String>) -> Self {
167        self.row_labels = labels;
168        self
169    }
170
171    /// Set column labels.
172    #[must_use]
173    pub fn with_col_labels(mut self, labels: Vec<String>) -> Self {
174        self.col_labels = labels;
175        self
176    }
177
178    /// Set color palette.
179    #[must_use]
180    pub fn with_palette(mut self, palette: HeatmapPalette) -> Self {
181        self.palette = palette;
182        self
183    }
184
185    /// Set value range.
186    #[must_use]
187    pub fn with_range(mut self, min: f64, max: f64) -> Self {
188        self.min = min;
189        self.max = max.max(min + 0.001);
190        self
191    }
192
193    /// Show cell values.
194    #[must_use]
195    pub fn with_values(mut self, show: bool) -> Self {
196        self.show_values = show;
197        self
198    }
199
200    /// Set cell dimensions.
201    #[must_use]
202    pub fn with_cell_size(mut self, width: u16, height: u16) -> Self {
203        self.cell_width = width.max(1);
204        self.cell_height = height.max(1);
205        self
206    }
207
208    /// Get number of rows.
209    #[must_use]
210    pub fn rows(&self) -> usize {
211        self.data.len()
212    }
213
214    /// Get number of columns.
215    #[must_use]
216    pub fn cols(&self) -> usize {
217        self.data.first().map_or(0, Vec::len)
218    }
219
220    fn compute_range(data: &[Vec<HeatmapCell>]) -> (f64, f64) {
221        let mut min = f64::MAX;
222        let mut max = f64::MIN;
223        for row in data {
224            for cell in row {
225                min = min.min(cell.value);
226                max = max.max(cell.value);
227            }
228        }
229        if min == f64::MAX {
230            (0.0, 1.0)
231        } else if (max - min).abs() < f64::EPSILON {
232            (min - 0.5, max + 0.5)
233        } else {
234            (min, max)
235        }
236    }
237
238    fn normalize(&self, value: f64) -> f64 {
239        let range = self.max - self.min;
240        if range.abs() < f64::EPSILON {
241            0.5
242        } else {
243            ((value - self.min) / range).clamp(0.0, 1.0)
244        }
245    }
246}
247
248impl Brick for Heatmap {
249    fn brick_name(&self) -> &'static str {
250        "heatmap"
251    }
252
253    fn assertions(&self) -> &[BrickAssertion] {
254        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
255        ASSERTIONS
256    }
257
258    fn budget(&self) -> BrickBudget {
259        BrickBudget::uniform(16)
260    }
261
262    fn verify(&self) -> BrickVerification {
263        BrickVerification {
264            passed: self.assertions().to_vec(),
265            failed: vec![],
266            verification_time: Duration::from_micros(10),
267        }
268    }
269
270    fn to_html(&self) -> String {
271        String::new()
272    }
273
274    fn to_css(&self) -> String {
275        String::new()
276    }
277}
278
279impl Widget for Heatmap {
280    fn type_id(&self) -> TypeId {
281        TypeId::of::<Self>()
282    }
283
284    fn measure(&self, constraints: Constraints) -> Size {
285        let label_width = self.row_labels.iter().map(String::len).max().unwrap_or(0) as f32;
286        let width = label_width + (self.cols() as f32 * self.cell_width as f32);
287        let height = if self.col_labels.is_empty() { 0.0 } else { 1.0 }
288            + (self.rows() as f32 * self.cell_height as f32);
289        constraints.constrain(Size::new(width, height))
290    }
291
292    fn layout(&mut self, bounds: Rect) -> LayoutResult {
293        self.bounds = bounds;
294        LayoutResult {
295            size: Size::new(bounds.width, bounds.height),
296        }
297    }
298
299    fn paint(&self, canvas: &mut dyn Canvas) {
300        if self.data.is_empty() {
301            return;
302        }
303
304        let label_width = self.row_labels.iter().map(String::len).max().unwrap_or(0) as f32;
305        let start_x = self.bounds.x + label_width;
306        let mut start_y = self.bounds.y;
307
308        // Draw column labels
309        if !self.col_labels.is_empty() {
310            let label_style = TextStyle {
311                color: Color::new(0.7, 0.7, 0.7, 1.0),
312                ..Default::default()
313            };
314            for (col, label) in self.col_labels.iter().enumerate() {
315                let x = start_x + (col as f32 * self.cell_width as f32);
316                let truncated: String = label.chars().take(self.cell_width as usize).collect();
317                canvas.draw_text(&truncated, Point::new(x, start_y), &label_style);
318            }
319            start_y += 1.0;
320        }
321
322        // Draw cells
323        for (row_idx, row) in self.data.iter().enumerate() {
324            let y = start_y + (row_idx as f32 * self.cell_height as f32);
325
326            // Row label
327            if let Some(label) = self.row_labels.get(row_idx) {
328                let label_style = TextStyle {
329                    color: Color::new(0.7, 0.7, 0.7, 1.0),
330                    ..Default::default()
331                };
332                canvas.draw_text(label, Point::new(self.bounds.x, y), &label_style);
333            }
334
335            // Cells
336            for (col_idx, cell) in row.iter().enumerate() {
337                let x = start_x + (col_idx as f32 * self.cell_width as f32);
338                let norm = self.normalize(cell.value);
339                let color = self.palette.color(norm);
340
341                // Fill cell background
342                canvas.fill_rect(
343                    Rect::new(x, y, self.cell_width as f32, self.cell_height as f32),
344                    color,
345                );
346
347                // Draw value or label
348                if self.show_values {
349                    let text = cell
350                        .label
351                        .clone()
352                        .unwrap_or_else(|| format!("{:.1}", cell.value));
353                    let text: String = text.chars().take(self.cell_width as usize).collect();
354
355                    // Use contrasting color for text
356                    let text_color = if norm > 0.5 {
357                        Color::BLACK
358                    } else {
359                        Color::WHITE
360                    };
361                    let text_style = TextStyle {
362                        color: text_color,
363                        ..Default::default()
364                    };
365                    canvas.draw_text(&text, Point::new(x, y), &text_style);
366                }
367            }
368        }
369    }
370
371    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
372        None
373    }
374
375    fn children(&self) -> &[Box<dyn Widget>] {
376        &[]
377    }
378
379    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
380        &mut []
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    struct MockCanvas {
389        texts: Vec<(String, Point)>,
390        rects: Vec<(Rect, Color)>,
391    }
392
393    impl MockCanvas {
394        fn new() -> Self {
395            Self {
396                texts: vec![],
397                rects: vec![],
398            }
399        }
400    }
401
402    impl Canvas for MockCanvas {
403        fn fill_rect(&mut self, rect: Rect, color: Color) {
404            self.rects.push((rect, color));
405        }
406        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
407        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
408            self.texts.push((text.to_string(), position));
409        }
410        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
411        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
412        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
413        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
414        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
415        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
416        fn push_clip(&mut self, _rect: Rect) {}
417        fn pop_clip(&mut self) {}
418        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
419        fn pop_transform(&mut self) {}
420    }
421
422    #[test]
423    fn test_heatmap_creation() {
424        let data = vec![
425            vec![HeatmapCell::new(1.0), HeatmapCell::new(2.0)],
426            vec![HeatmapCell::new(3.0), HeatmapCell::new(4.0)],
427        ];
428        let heatmap = Heatmap::new(data);
429        assert_eq!(heatmap.rows(), 2);
430        assert_eq!(heatmap.cols(), 2);
431    }
432
433    #[test]
434    fn test_heatmap_from_values() {
435        let heatmap = Heatmap::from_values(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
436        assert_eq!(heatmap.rows(), 2);
437        assert_eq!(heatmap.cols(), 2);
438    }
439
440    #[test]
441    fn test_heatmap_assertions() {
442        let heatmap = Heatmap::default();
443        assert!(!heatmap.assertions().is_empty());
444    }
445
446    #[test]
447    fn test_heatmap_verify() {
448        let heatmap = Heatmap::default();
449        assert!(heatmap.verify().is_valid());
450    }
451
452    #[test]
453    fn test_heatmap_with_palette() {
454        let heatmap = Heatmap::default().with_palette(HeatmapPalette::Viridis);
455        assert_eq!(heatmap.palette, HeatmapPalette::Viridis);
456    }
457
458    #[test]
459    fn test_heatmap_with_range() {
460        let heatmap = Heatmap::default().with_range(0.0, 100.0);
461        assert_eq!(heatmap.min, 0.0);
462        assert_eq!(heatmap.max, 100.0);
463    }
464
465    #[test]
466    fn test_heatmap_with_values() {
467        let heatmap = Heatmap::default().with_values(true);
468        assert!(heatmap.show_values);
469    }
470
471    #[test]
472    fn test_heatmap_with_cell_size() {
473        let heatmap = Heatmap::default().with_cell_size(6, 2);
474        assert_eq!(heatmap.cell_width, 6);
475        assert_eq!(heatmap.cell_height, 2);
476    }
477
478    #[test]
479    fn test_heatmap_with_labels() {
480        let heatmap = Heatmap::default()
481            .with_row_labels(vec!["A".to_string(), "B".to_string()])
482            .with_col_labels(vec!["X".to_string(), "Y".to_string()]);
483        assert_eq!(heatmap.row_labels.len(), 2);
484        assert_eq!(heatmap.col_labels.len(), 2);
485    }
486
487    #[test]
488    fn test_heatmap_paint() {
489        let mut heatmap = Heatmap::from_values(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
490        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
491        let mut canvas = MockCanvas::new();
492        heatmap.paint(&mut canvas);
493        assert!(!canvas.rects.is_empty());
494    }
495
496    #[test]
497    fn test_heatmap_paint_with_values() {
498        let mut heatmap = Heatmap::from_values(vec![vec![1.0, 2.0]]).with_values(true);
499        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
500        let mut canvas = MockCanvas::new();
501        heatmap.paint(&mut canvas);
502        assert!(!canvas.texts.is_empty());
503    }
504
505    #[test]
506    fn test_heatmap_paint_with_labels() {
507        let mut heatmap = Heatmap::from_values(vec![vec![1.0]])
508            .with_row_labels(vec!["Row".to_string()])
509            .with_col_labels(vec!["Col".to_string()]);
510        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
511        let mut canvas = MockCanvas::new();
512        heatmap.paint(&mut canvas);
513        assert!(!canvas.texts.is_empty());
514    }
515
516    #[test]
517    fn test_heatmap_empty() {
518        let mut heatmap = Heatmap::default();
519        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
520        let mut canvas = MockCanvas::new();
521        heatmap.paint(&mut canvas);
522        assert!(canvas.rects.is_empty());
523    }
524
525    #[test]
526    fn test_palette_blue_red() {
527        let palette = HeatmapPalette::BlueRed;
528        let _low = palette.color(0.0);
529        let _mid = palette.color(0.5);
530        let _high = palette.color(1.0);
531    }
532
533    #[test]
534    fn test_palette_viridis() {
535        let palette = HeatmapPalette::Viridis;
536        let _low = palette.color(0.0);
537        let _mid = palette.color(0.5);
538        let _high = palette.color(1.0);
539    }
540
541    #[test]
542    fn test_palette_green_red() {
543        let palette = HeatmapPalette::GreenRed;
544        let low = palette.color(0.0);
545        let high = palette.color(1.0);
546        assert!(low.g > low.r);
547        assert!(high.r > high.g);
548    }
549
550    #[test]
551    fn test_palette_grayscale() {
552        let palette = HeatmapPalette::Grayscale;
553        let mid = palette.color(0.5);
554        assert!((mid.r - 0.5).abs() < 0.01);
555    }
556
557    #[test]
558    fn test_palette_mono() {
559        let palette = HeatmapPalette::Mono(255, 0, 0);
560        let full = palette.color(1.0);
561        assert!((full.r - 1.0).abs() < 0.01);
562    }
563
564    #[test]
565    fn test_heatmap_cell_with_label() {
566        let cell = HeatmapCell::with_label(5.0, "test");
567        assert_eq!(cell.value, 5.0);
568        assert_eq!(cell.label, Some("test".to_string()));
569    }
570
571    #[test]
572    fn test_heatmap_measure() {
573        let heatmap = Heatmap::from_values(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
574        let size = heatmap.measure(Constraints::loose(Size::new(100.0, 100.0)));
575        assert!(size.width > 0.0);
576        assert!(size.height > 0.0);
577    }
578
579    #[test]
580    fn test_heatmap_layout() {
581        let mut heatmap = Heatmap::from_values(vec![vec![1.0]]);
582        let bounds = Rect::new(5.0, 10.0, 30.0, 20.0);
583        let result = heatmap.layout(bounds);
584        assert_eq!(result.size.width, 30.0);
585        assert_eq!(heatmap.bounds, bounds);
586    }
587
588    #[test]
589    fn test_heatmap_brick_name() {
590        let heatmap = Heatmap::default();
591        assert_eq!(heatmap.brick_name(), "heatmap");
592    }
593
594    #[test]
595    fn test_heatmap_type_id() {
596        let heatmap = Heatmap::default();
597        assert_eq!(Widget::type_id(&heatmap), TypeId::of::<Heatmap>());
598    }
599
600    #[test]
601    fn test_heatmap_children() {
602        let heatmap = Heatmap::default();
603        assert!(heatmap.children().is_empty());
604    }
605
606    #[test]
607    fn test_heatmap_event() {
608        let mut heatmap = Heatmap::default();
609        let event = Event::KeyDown {
610            key: presentar_core::Key::Enter,
611        };
612        assert!(heatmap.event(&event).is_none());
613    }
614
615    #[test]
616    fn test_heatmap_children_mut() {
617        let mut heatmap = Heatmap::default();
618        assert!(heatmap.children_mut().is_empty());
619    }
620
621    #[test]
622    fn test_heatmap_to_html() {
623        let heatmap = Heatmap::default();
624        assert!(heatmap.to_html().is_empty());
625    }
626
627    #[test]
628    fn test_heatmap_to_css() {
629        let heatmap = Heatmap::default();
630        assert!(heatmap.to_css().is_empty());
631    }
632
633    #[test]
634    fn test_heatmap_budget() {
635        let heatmap = Heatmap::default();
636        let budget = heatmap.budget();
637        assert_eq!(budget.total_ms, 16);
638    }
639
640    #[test]
641    fn test_heatmap_same_value_range() {
642        // All cells have the same value - edge case for compute_range
643        let heatmap = Heatmap::from_values(vec![vec![5.0, 5.0], vec![5.0, 5.0]]);
644        // Range should be padded to avoid division by zero
645        assert!(heatmap.min < heatmap.max);
646    }
647
648    #[test]
649    fn test_heatmap_with_cell_labels() {
650        let data = vec![vec![
651            HeatmapCell::with_label(1.0, "A"),
652            HeatmapCell::with_label(2.0, "B"),
653        ]];
654        let mut heatmap = Heatmap::new(data).with_values(true);
655        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
656        let mut canvas = MockCanvas::new();
657        heatmap.paint(&mut canvas);
658        // Should show cell labels
659        assert!(canvas.texts.iter().any(|(t, _)| t == "A" || t == "B"));
660    }
661
662    #[test]
663    fn test_heatmap_high_value_contrast() {
664        // High normalized value should use dark text
665        let mut heatmap = Heatmap::from_values(vec![vec![10.0]]).with_values(true);
666        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
667        let mut canvas = MockCanvas::new();
668        heatmap.paint(&mut canvas);
669        // Should have text drawn
670        assert!(!canvas.texts.is_empty());
671    }
672
673    #[test]
674    fn test_heatmap_low_value_contrast() {
675        // Low normalized value should use light text
676        let mut heatmap = Heatmap::from_values(vec![vec![1.0, 10.0]]).with_values(true);
677        heatmap.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
678        let mut canvas = MockCanvas::new();
679        heatmap.paint(&mut canvas);
680        // Should have text drawn
681        assert!(!canvas.texts.is_empty());
682    }
683
684    #[test]
685    fn test_palette_blue_red_low() {
686        let palette = HeatmapPalette::BlueRed;
687        let low = palette.color(0.0);
688        // Low value: should be blue-ish
689        assert!(low.b > low.r);
690    }
691
692    #[test]
693    fn test_palette_blue_red_high() {
694        let palette = HeatmapPalette::BlueRed;
695        let high = palette.color(1.0);
696        // High value: should be red-ish
697        assert!(high.r > high.b);
698    }
699
700    #[test]
701    fn test_heatmap_measure_with_labels() {
702        let heatmap = Heatmap::from_values(vec![vec![1.0, 2.0]])
703            .with_row_labels(vec!["LongRowLabel".to_string()])
704            .with_col_labels(vec!["A".to_string(), "B".to_string()]);
705        let size = heatmap.measure(Constraints::loose(Size::new(100.0, 100.0)));
706        // Width should include label width
707        assert!(size.width > 0.0);
708        // Height should include column label row
709        assert!(size.height >= 2.0);
710    }
711
712    #[test]
713    fn test_heatmap_with_range_min_equals_max() {
714        let heatmap = Heatmap::default().with_range(5.0, 5.0);
715        // Max should be adjusted to avoid zero range
716        assert!(heatmap.max > heatmap.min);
717    }
718
719    #[test]
720    fn test_heatmap_cell_size_min() {
721        let heatmap = Heatmap::default().with_cell_size(0, 0);
722        // Should enforce minimum of 1
723        assert_eq!(heatmap.cell_width, 1);
724        assert_eq!(heatmap.cell_height, 1);
725    }
726}