Skip to main content

oxidize_pdf/dashboard/
heatmap.rs

1//! HeatMap Visualization Component
2//!
3//! This module implements heat maps for dashboard visualizations, displaying
4//! data intensity through color gradients in a matrix format.
5
6use super::{
7    component::ComponentConfig, ComponentPosition, ComponentSpan, DashboardComponent,
8    DashboardTheme,
9};
10use crate::error::PdfError;
11use crate::graphics::Color;
12use crate::page::Page;
13
14/// HeatMap visualization component
15#[derive(Debug, Clone)]
16pub struct HeatMap {
17    /// Component configuration
18    config: ComponentConfig,
19    /// Heat map data
20    data: HeatMapData,
21    /// Configuration options
22    options: HeatMapOptions,
23    /// Color scale for the heat map
24    color_scale: ColorScale,
25}
26
27impl HeatMap {
28    /// Create a new heat map
29    pub fn new(data: HeatMapData) -> Self {
30        Self {
31            config: ComponentConfig::new(ComponentSpan::new(6)), // Half width by default
32            data,
33            options: HeatMapOptions::default(),
34            color_scale: ColorScale::default(),
35        }
36    }
37
38    /// Set heat map options
39    pub fn with_options(mut self, options: HeatMapOptions) -> Self {
40        self.options = options;
41        self
42    }
43
44    /// Set color scale
45    pub fn with_color_scale(mut self, color_scale: ColorScale) -> Self {
46        self.color_scale = color_scale;
47        self
48    }
49
50    /// Get min/max values from the data
51    fn get_value_range(&self) -> (f64, f64) {
52        let min_val = self.color_scale.min_value.unwrap_or_else(|| {
53            self.data
54                .values
55                .iter()
56                .flat_map(|row| row.iter())
57                .copied()
58                .fold(f64::INFINITY, f64::min)
59        });
60
61        let max_val = self.color_scale.max_value.unwrap_or_else(|| {
62            self.data
63                .values
64                .iter()
65                .flat_map(|row| row.iter())
66                .copied()
67                .fold(f64::NEG_INFINITY, f64::max)
68        });
69
70        (min_val, max_val)
71    }
72
73    /// Interpolate color based on value
74    fn interpolate_color(&self, value: f64, min_val: f64, max_val: f64) -> Color {
75        if max_val == min_val {
76            return self.color_scale.colors[0];
77        }
78
79        let normalized = ((value - min_val) / (max_val - min_val)).clamp(0.0, 1.0);
80
81        if self.color_scale.colors.len() == 1 {
82            return self.color_scale.colors[0];
83        }
84
85        // Interpolate between colors
86        let segment_count = self.color_scale.colors.len() - 1;
87        let segment = (normalized * segment_count as f64).floor() as usize;
88        let segment = segment.min(segment_count - 1);
89
90        let t = (normalized * segment_count as f64) - segment as f64;
91
92        let c1 = &self.color_scale.colors[segment];
93        let c2 = &self.color_scale.colors[segment + 1];
94
95        // Extract RGB components from both colors
96        let (r1, g1, b1) = match c1 {
97            Color::Rgb(r, g, b) => (*r, *g, *b),
98            Color::Gray(v) => (*v, *v, *v),
99            Color::Cmyk(c, m, y, k) => {
100                // Simple CMYK to RGB conversion
101                let r = (1.0 - c) * (1.0 - k);
102                let g = (1.0 - m) * (1.0 - k);
103                let b = (1.0 - y) * (1.0 - k);
104                (r, g, b)
105            }
106        };
107
108        let (r2, g2, b2) = match c2 {
109            Color::Rgb(r, g, b) => (*r, *g, *b),
110            Color::Gray(v) => (*v, *v, *v),
111            Color::Cmyk(c, m, y, k) => {
112                let r = (1.0 - c) * (1.0 - k);
113                let g = (1.0 - m) * (1.0 - k);
114                let b = (1.0 - y) * (1.0 - k);
115                (r, g, b)
116            }
117        };
118
119        Color::rgb(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
120    }
121
122    /// Check if a color is dark (for text contrast)
123    fn is_dark_color(&self, color: &Color) -> bool {
124        // Using relative luminance formula
125        let (r, g, b) = match color {
126            Color::Rgb(r, g, b) => (*r, *g, *b),
127            Color::Gray(v) => (*v, *v, *v),
128            Color::Cmyk(c, m, y, k) => {
129                let r = (1.0 - c) * (1.0 - k);
130                let g = (1.0 - m) * (1.0 - k);
131                let b = (1.0 - y) * (1.0 - k);
132                (r, g, b)
133            }
134        };
135        let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
136        luminance < 0.5
137    }
138
139    /// Render the color legend
140    fn render_legend(
141        &self,
142        page: &mut Page,
143        _position: ComponentPosition,
144        x: f64,
145        y: f64,
146        width: f64,
147        height: f64,
148        min_val: f64,
149        max_val: f64,
150        theme: &DashboardTheme,
151    ) -> Result<(), PdfError> {
152        let steps = 20;
153        let step_height = height / steps as f64;
154
155        // Draw gradient
156        for i in 0..steps {
157            let value = min_val + (max_val - min_val) * (i as f64 / steps as f64);
158            let color = self.interpolate_color(value, min_val, max_val);
159            let step_y = y + (steps - 1 - i) as f64 * step_height;
160
161            page.graphics()
162                .set_fill_color(color)
163                .rect(x, step_y, width, step_height)
164                .fill();
165        }
166
167        // Draw border
168        page.graphics()
169            .set_stroke_color(Color::gray(0.5))
170            .set_line_width(1.0)
171            .rect(x, y, width, height)
172            .stroke();
173
174        // Draw min/max labels
175        page.text()
176            .set_font(crate::Font::Helvetica, 8.0)
177            .set_fill_color(theme.colors.text_secondary)
178            .at(x + width + 5.0, y - 5.0)
179            .write(&format!("{:.1}", max_val))?;
180
181        page.text()
182            .set_font(crate::Font::Helvetica, 8.0)
183            .set_fill_color(theme.colors.text_secondary)
184            .at(x + width + 5.0, y + height - 10.0)
185            .write(&format!("{:.1}", min_val))?;
186
187        Ok(())
188    }
189}
190
191impl DashboardComponent for HeatMap {
192    fn render(
193        &self,
194        page: &mut Page,
195        position: ComponentPosition,
196        theme: &DashboardTheme,
197    ) -> Result<(), PdfError> {
198        let title = self.options.title.as_deref().unwrap_or("HeatMap");
199
200        // Calculate dimensions
201        let title_height = 30.0;
202        let legend_width = if self.options.show_legend { 60.0 } else { 0.0 };
203        let label_width = 80.0;
204        let label_height = 30.0;
205
206        let chart_x = position.x + label_width;
207        let chart_y = position.y;
208        let chart_width = position.width - label_width - legend_width;
209        let chart_height = position.height - title_height - label_height;
210
211        // Render title
212        page.text()
213            .set_font(crate::Font::HelveticaBold, theme.typography.heading_size)
214            .set_fill_color(theme.colors.text_primary)
215            .at(position.x, position.y + position.height - 15.0)
216            .write(title)?;
217
218        // Calculate cell dimensions
219        let rows = self.data.values.len();
220        let cols = if rows > 0 {
221            self.data.values[0].len()
222        } else {
223            0
224        };
225
226        if rows == 0 || cols == 0 {
227            return Ok(());
228        }
229
230        let cell_width = chart_width / cols as f64;
231        let cell_height = chart_height / rows as f64;
232
233        // Find min/max values for color scaling
234        let (min_val, max_val) = self.get_value_range();
235
236        // Render cells
237        for (row_idx, row) in self.data.values.iter().enumerate() {
238            for (col_idx, &value) in row.iter().enumerate() {
239                let x = chart_x + col_idx as f64 * cell_width;
240                let y = chart_y + title_height + (rows - 1 - row_idx) as f64 * cell_height;
241
242                // Get color for this value
243                let color = self.interpolate_color(value, min_val, max_val);
244
245                // Draw cell
246                page.graphics()
247                    .set_fill_color(color)
248                    .rect(
249                        x + self.options.cell_padding,
250                        y + self.options.cell_padding,
251                        cell_width - 2.0 * self.options.cell_padding,
252                        cell_height - 2.0 * self.options.cell_padding,
253                    )
254                    .fill();
255
256                // Draw cell border
257                page.graphics()
258                    .set_stroke_color(Color::gray(0.8))
259                    .set_line_width(0.5)
260                    .rect(
261                        x + self.options.cell_padding,
262                        y + self.options.cell_padding,
263                        cell_width - 2.0 * self.options.cell_padding,
264                        cell_height - 2.0 * self.options.cell_padding,
265                    )
266                    .stroke();
267
268                // Optionally show values
269                if self.options.show_values && cell_width > 40.0 && cell_height > 20.0 {
270                    let text_color = if self.is_dark_color(&color) {
271                        Color::white()
272                    } else {
273                        Color::black()
274                    };
275
276                    page.text()
277                        .set_font(crate::Font::Helvetica, 8.0)
278                        .set_fill_color(text_color)
279                        .at(x + cell_width / 2.0 - 10.0, y + cell_height / 2.0 - 3.0)
280                        .write(&format!("{:.1}", value))?;
281                }
282            }
283        }
284
285        // Render row labels
286        for (idx, label) in self.data.row_labels.iter().enumerate() {
287            let y = chart_y + title_height + (rows - 1 - idx) as f64 * cell_height;
288            page.text()
289                .set_font(crate::Font::Helvetica, 9.0)
290                .set_fill_color(theme.colors.text_secondary)
291                .at(position.x + 5.0, y + cell_height / 2.0 - 3.0)
292                .write(label)?;
293        }
294
295        // Render column labels
296        for (idx, label) in self.data.column_labels.iter().enumerate() {
297            let x = chart_x + idx as f64 * cell_width;
298
299            // Rotate text for better fit
300            page.text()
301                .set_font(crate::Font::Helvetica, 9.0)
302                .set_fill_color(theme.colors.text_secondary)
303                .at(x + cell_width / 2.0 - 5.0, chart_y + 10.0)
304                .write(label)?;
305        }
306
307        // Render legend
308        if self.options.show_legend {
309            self.render_legend(
310                page,
311                position,
312                chart_x + chart_width + 10.0,
313                chart_y + title_height,
314                legend_width - 20.0,
315                chart_height,
316                min_val,
317                max_val,
318                theme,
319            )?;
320        }
321
322        Ok(())
323    }
324
325    fn get_span(&self) -> ComponentSpan {
326        self.config.span
327    }
328    fn set_span(&mut self, span: ComponentSpan) {
329        self.config.span = span;
330    }
331    fn preferred_height(&self, _available_width: f64) -> f64 {
332        300.0
333    }
334    fn component_type(&self) -> &'static str {
335        "HeatMap"
336    }
337    fn complexity_score(&self) -> u8 {
338        75
339    }
340}
341
342/// HeatMap data structure
343#[derive(Debug, Clone)]
344pub struct HeatMapData {
345    pub values: Vec<Vec<f64>>,
346    pub row_labels: Vec<String>,
347    pub column_labels: Vec<String>,
348}
349
350/// HeatMap configuration options
351#[derive(Debug, Clone)]
352pub struct HeatMapOptions {
353    pub title: Option<String>,
354    pub show_legend: bool,
355    pub show_values: bool,
356    pub cell_padding: f64,
357}
358
359impl Default for HeatMapOptions {
360    fn default() -> Self {
361        Self {
362            title: None,
363            show_legend: true,
364            show_values: false,
365            cell_padding: 2.0,
366        }
367    }
368}
369
370/// Color scale for heat maps
371#[derive(Debug, Clone)]
372pub struct ColorScale {
373    pub colors: Vec<Color>,
374    pub min_value: Option<f64>,
375    pub max_value: Option<f64>,
376}
377
378impl Default for ColorScale {
379    fn default() -> Self {
380        Self {
381            colors: vec![
382                Color::hex("#ffffff"), // White for minimum
383                Color::hex("#ff0000"), // Red for maximum
384            ],
385            min_value: None,
386            max_value: None,
387        }
388    }
389}
390
391/// Builder for HeatMap
392pub struct HeatMapBuilder;
393
394impl HeatMapBuilder {
395    pub fn new() -> Self {
396        Self
397    }
398    pub fn build(self) -> HeatMap {
399        HeatMap::new(HeatMapData {
400            values: vec![],
401            row_labels: vec![],
402            column_labels: vec![],
403        })
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn sample_heatmap_data() -> HeatMapData {
412        HeatMapData {
413            values: vec![
414                vec![1.0, 2.0, 3.0],
415                vec![4.0, 5.0, 6.0],
416                vec![7.0, 8.0, 9.0],
417            ],
418            row_labels: vec!["Row1".to_string(), "Row2".to_string(), "Row3".to_string()],
419            column_labels: vec!["Col1".to_string(), "Col2".to_string(), "Col3".to_string()],
420        }
421    }
422
423    #[test]
424    fn test_heatmap_new() {
425        let data = sample_heatmap_data();
426        let heatmap = HeatMap::new(data.clone());
427
428        assert_eq!(heatmap.data.values.len(), 3);
429        assert_eq!(heatmap.data.row_labels.len(), 3);
430        assert_eq!(heatmap.data.column_labels.len(), 3);
431    }
432
433    #[test]
434    fn test_heatmap_with_options() {
435        let data = sample_heatmap_data();
436        let options = HeatMapOptions {
437            title: Some("Test HeatMap".to_string()),
438            show_legend: false,
439            show_values: true,
440            cell_padding: 5.0,
441        };
442
443        let heatmap = HeatMap::new(data).with_options(options.clone());
444
445        assert_eq!(heatmap.options.title, Some("Test HeatMap".to_string()));
446        assert!(!heatmap.options.show_legend);
447        assert!(heatmap.options.show_values);
448        assert_eq!(heatmap.options.cell_padding, 5.0);
449    }
450
451    #[test]
452    fn test_heatmap_with_color_scale() {
453        let data = sample_heatmap_data();
454        let color_scale = ColorScale {
455            colors: vec![Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 0.0, 0.0)],
456            min_value: Some(0.0),
457            max_value: Some(10.0),
458        };
459
460        let heatmap = HeatMap::new(data).with_color_scale(color_scale);
461
462        assert_eq!(heatmap.color_scale.colors.len(), 2);
463        assert_eq!(heatmap.color_scale.min_value, Some(0.0));
464        assert_eq!(heatmap.color_scale.max_value, Some(10.0));
465    }
466
467    #[test]
468    fn test_heatmap_options_default() {
469        let options = HeatMapOptions::default();
470
471        assert!(options.title.is_none());
472        assert!(options.show_legend);
473        assert!(!options.show_values);
474        assert_eq!(options.cell_padding, 2.0);
475    }
476
477    #[test]
478    fn test_color_scale_default() {
479        let scale = ColorScale::default();
480
481        assert_eq!(scale.colors.len(), 2);
482        assert!(scale.min_value.is_none());
483        assert!(scale.max_value.is_none());
484    }
485
486    #[test]
487    fn test_heatmap_builder() {
488        let builder = HeatMapBuilder::new();
489        let heatmap = builder.build();
490
491        assert!(heatmap.data.values.is_empty());
492        assert!(heatmap.data.row_labels.is_empty());
493        assert!(heatmap.data.column_labels.is_empty());
494    }
495
496    #[test]
497    fn test_get_value_range_auto() {
498        let data = sample_heatmap_data();
499        let heatmap = HeatMap::new(data);
500
501        let (min, max) = heatmap.get_value_range();
502
503        assert_eq!(min, 1.0);
504        assert_eq!(max, 9.0);
505    }
506
507    #[test]
508    fn test_get_value_range_with_explicit_values() {
509        let data = sample_heatmap_data();
510        let color_scale = ColorScale {
511            colors: vec![Color::white(), Color::rgb(1.0, 0.0, 0.0)],
512            min_value: Some(-10.0),
513            max_value: Some(20.0),
514        };
515        let heatmap = HeatMap::new(data).with_color_scale(color_scale);
516
517        let (min, max) = heatmap.get_value_range();
518
519        assert_eq!(min, -10.0);
520        assert_eq!(max, 20.0);
521    }
522
523    #[test]
524    fn test_interpolate_color_at_minimum() {
525        let data = sample_heatmap_data();
526        let heatmap = HeatMap::new(data);
527
528        let color = heatmap.interpolate_color(0.0, 0.0, 100.0);
529
530        // Should be close to first color in default scale (white)
531        match color {
532            Color::Rgb(r, g, b) => {
533                assert!(r >= 0.9, "Red component should be high for white");
534                assert!(g >= 0.9, "Green component should be high for white");
535                assert!(b >= 0.9, "Blue component should be high for white");
536            }
537            _ => panic!("Expected RGB color"),
538        }
539    }
540
541    #[test]
542    fn test_interpolate_color_at_maximum() {
543        let data = sample_heatmap_data();
544        let heatmap = HeatMap::new(data);
545
546        let color = heatmap.interpolate_color(100.0, 0.0, 100.0);
547
548        // Should be close to last color in default scale (red)
549        match color {
550            Color::Rgb(r, g, b) => {
551                assert!(r >= 0.9, "Red component should be high for red");
552                assert!(g <= 0.1, "Green component should be low for red");
553                assert!(b <= 0.1, "Blue component should be low for red");
554            }
555            _ => panic!("Expected RGB color"),
556        }
557    }
558
559    #[test]
560    fn test_interpolate_color_at_midpoint() {
561        let data = sample_heatmap_data();
562        let heatmap = HeatMap::new(data);
563
564        let color = heatmap.interpolate_color(50.0, 0.0, 100.0);
565
566        // Should be interpolated between white and red
567        match color {
568            Color::Rgb(r, g, b) => {
569                assert!(r >= 0.9, "Red component should remain high");
570                assert!(g >= 0.4 && g <= 0.6, "Green should be around 0.5");
571                assert!(b >= 0.4 && b <= 0.6, "Blue should be around 0.5");
572            }
573            _ => panic!("Expected RGB color"),
574        }
575    }
576
577    #[test]
578    fn test_interpolate_color_same_min_max() {
579        let data = sample_heatmap_data();
580        let heatmap = HeatMap::new(data);
581
582        // When min == max, should return first color
583        let color = heatmap.interpolate_color(5.0, 5.0, 5.0);
584
585        // Should be the first color in the scale
586        assert!(matches!(color, Color::Rgb(_, _, _)));
587    }
588
589    #[test]
590    fn test_interpolate_color_single_color_scale() {
591        let data = sample_heatmap_data();
592        let color_scale = ColorScale {
593            colors: vec![Color::rgb(0.5, 0.5, 0.5)],
594            min_value: None,
595            max_value: None,
596        };
597        let heatmap = HeatMap::new(data).with_color_scale(color_scale);
598
599        let color = heatmap.interpolate_color(50.0, 0.0, 100.0);
600
601        match color {
602            Color::Rgb(r, g, b) => {
603                assert!((r - 0.5).abs() < 0.01);
604                assert!((g - 0.5).abs() < 0.01);
605                assert!((b - 0.5).abs() < 0.01);
606            }
607            _ => panic!("Expected RGB color"),
608        }
609    }
610
611    #[test]
612    fn test_is_dark_color_with_black() {
613        let data = sample_heatmap_data();
614        let heatmap = HeatMap::new(data);
615
616        assert!(heatmap.is_dark_color(&Color::rgb(0.0, 0.0, 0.0)));
617    }
618
619    #[test]
620    fn test_is_dark_color_with_white() {
621        let data = sample_heatmap_data();
622        let heatmap = HeatMap::new(data);
623
624        assert!(!heatmap.is_dark_color(&Color::rgb(1.0, 1.0, 1.0)));
625    }
626
627    #[test]
628    fn test_is_dark_color_with_red() {
629        let data = sample_heatmap_data();
630        let heatmap = HeatMap::new(data);
631
632        // Pure red has luminance = 0.299, which is < 0.5
633        assert!(heatmap.is_dark_color(&Color::rgb(1.0, 0.0, 0.0)));
634    }
635
636    #[test]
637    fn test_is_dark_color_with_gray() {
638        let data = sample_heatmap_data();
639        let heatmap = HeatMap::new(data);
640
641        // Gray(0.3) should be dark
642        assert!(heatmap.is_dark_color(&Color::Gray(0.3)));
643        // Gray(0.7) should be light
644        assert!(!heatmap.is_dark_color(&Color::Gray(0.7)));
645    }
646
647    #[test]
648    fn test_is_dark_color_with_cmyk() {
649        let data = sample_heatmap_data();
650        let heatmap = HeatMap::new(data);
651
652        // CMYK black (0, 0, 0, 1) should be dark
653        assert!(heatmap.is_dark_color(&Color::Cmyk(0.0, 0.0, 0.0, 1.0)));
654        // CMYK white-ish (0, 0, 0, 0) should be light
655        assert!(!heatmap.is_dark_color(&Color::Cmyk(0.0, 0.0, 0.0, 0.0)));
656    }
657
658    #[test]
659    fn test_heatmap_data_creation() {
660        let data = HeatMapData {
661            values: vec![vec![1.0, 2.0], vec![3.0, 4.0]],
662            row_labels: vec!["A".to_string(), "B".to_string()],
663            column_labels: vec!["X".to_string(), "Y".to_string()],
664        };
665
666        assert_eq!(data.values.len(), 2);
667        assert_eq!(data.values[0].len(), 2);
668        assert_eq!(data.row_labels[0], "A");
669        assert_eq!(data.column_labels[1], "Y");
670    }
671
672    #[test]
673    fn test_component_span() {
674        let data = sample_heatmap_data();
675        let mut heatmap = HeatMap::new(data);
676
677        // Default span
678        let span = heatmap.get_span();
679        assert_eq!(span.columns, 6);
680
681        // Set new span
682        heatmap.set_span(ComponentSpan::new(12));
683        assert_eq!(heatmap.get_span().columns, 12);
684    }
685
686    #[test]
687    fn test_component_type() {
688        let data = sample_heatmap_data();
689        let heatmap = HeatMap::new(data);
690
691        assert_eq!(heatmap.component_type(), "HeatMap");
692    }
693
694    #[test]
695    fn test_complexity_score() {
696        let data = sample_heatmap_data();
697        let heatmap = HeatMap::new(data);
698
699        assert_eq!(heatmap.complexity_score(), 75);
700    }
701
702    #[test]
703    fn test_preferred_height() {
704        let data = sample_heatmap_data();
705        let heatmap = HeatMap::new(data);
706
707        assert_eq!(heatmap.preferred_height(1000.0), 300.0);
708    }
709
710    #[test]
711    fn test_interpolate_color_multi_color_scale() {
712        let data = sample_heatmap_data();
713        let color_scale = ColorScale {
714            colors: vec![
715                Color::rgb(0.0, 0.0, 1.0), // Blue
716                Color::rgb(0.0, 1.0, 0.0), // Green
717                Color::rgb(1.0, 0.0, 0.0), // Red
718            ],
719            min_value: None,
720            max_value: None,
721        };
722        let heatmap = HeatMap::new(data).with_color_scale(color_scale);
723
724        // At 0%, should be blue
725        let color_start = heatmap.interpolate_color(0.0, 0.0, 100.0);
726        match color_start {
727            Color::Rgb(r, g, b) => {
728                assert!(r < 0.1);
729                assert!(g < 0.1);
730                assert!(b > 0.9);
731            }
732            _ => panic!("Expected RGB"),
733        }
734
735        // At 50%, should be green
736        let color_mid = heatmap.interpolate_color(50.0, 0.0, 100.0);
737        match color_mid {
738            Color::Rgb(r, g, b) => {
739                assert!(r < 0.1);
740                assert!(g > 0.9);
741                assert!(b < 0.1);
742            }
743            _ => panic!("Expected RGB"),
744        }
745
746        // At 100%, should be red
747        let color_end = heatmap.interpolate_color(100.0, 0.0, 100.0);
748        match color_end {
749            Color::Rgb(r, g, b) => {
750                assert!(r > 0.9);
751                assert!(g < 0.1);
752                assert!(b < 0.1);
753            }
754            _ => panic!("Expected RGB"),
755        }
756    }
757
758    #[test]
759    fn test_get_value_range_empty_data() {
760        let data = HeatMapData {
761            values: vec![],
762            row_labels: vec![],
763            column_labels: vec![],
764        };
765        let heatmap = HeatMap::new(data);
766
767        let (min, max) = heatmap.get_value_range();
768
769        // With empty data, should return infinity values
770        assert!(min.is_infinite());
771        assert!(max.is_infinite());
772    }
773
774    #[test]
775    fn test_get_value_range_negative_values() {
776        let data = HeatMapData {
777            values: vec![vec![-10.0, -5.0], vec![0.0, 5.0]],
778            row_labels: vec!["A".to_string(), "B".to_string()],
779            column_labels: vec!["X".to_string(), "Y".to_string()],
780        };
781        let heatmap = HeatMap::new(data);
782
783        let (min, max) = heatmap.get_value_range();
784
785        assert_eq!(min, -10.0);
786        assert_eq!(max, 5.0);
787    }
788
789    #[test]
790    fn test_interpolate_color_clamping() {
791        let data = sample_heatmap_data();
792        let heatmap = HeatMap::new(data);
793
794        // Value below min should clamp
795        let color_below = heatmap.interpolate_color(-100.0, 0.0, 100.0);
796        let color_at_min = heatmap.interpolate_color(0.0, 0.0, 100.0);
797
798        // Both should produce similar colors (clamped to min)
799        match (color_below, color_at_min) {
800            (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
801                assert!((r1 - r2).abs() < 0.01);
802                assert!((g1 - g2).abs() < 0.01);
803                assert!((b1 - b2).abs() < 0.01);
804            }
805            _ => panic!("Expected RGB colors"),
806        }
807
808        // Value above max should clamp
809        let color_above = heatmap.interpolate_color(200.0, 0.0, 100.0);
810        let color_at_max = heatmap.interpolate_color(100.0, 0.0, 100.0);
811
812        match (color_above, color_at_max) {
813            (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
814                assert!((r1 - r2).abs() < 0.01);
815                assert!((g1 - g2).abs() < 0.01);
816                assert!((b1 - b2).abs() < 0.01);
817            }
818            _ => panic!("Expected RGB colors"),
819        }
820    }
821}