1use super::{
7 component::ComponentConfig, ComponentPosition, ComponentSpan, DashboardComponent,
8 DashboardTheme,
9};
10use crate::error::PdfError;
11use crate::graphics::Color;
12use crate::page::Page;
13
14#[derive(Debug, Clone)]
16pub struct HeatMap {
17 config: ComponentConfig,
19 data: HeatMapData,
21 options: HeatMapOptions,
23 color_scale: ColorScale,
25}
26
27impl HeatMap {
28 pub fn new(data: HeatMapData) -> Self {
30 Self {
31 config: ComponentConfig::new(ComponentSpan::new(6)), data,
33 options: HeatMapOptions::default(),
34 color_scale: ColorScale::default(),
35 }
36 }
37
38 pub fn with_options(mut self, options: HeatMapOptions) -> Self {
40 self.options = options;
41 self
42 }
43
44 pub fn with_color_scale(mut self, color_scale: ColorScale) -> Self {
46 self.color_scale = color_scale;
47 self
48 }
49
50 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 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 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 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 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 fn is_dark_color(&self, color: &Color) -> bool {
124 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 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 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 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 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 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 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 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 let (min_val, max_val) = self.get_value_range();
235
236 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 let color = self.interpolate_color(value, min_val, max_val);
244
245 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 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 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 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 for (idx, label) in self.data.column_labels.iter().enumerate() {
297 let x = chart_x + idx as f64 * cell_width;
298
299 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 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#[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#[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#[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"), Color::hex("#ff0000"), ],
385 min_value: None,
386 max_value: None,
387 }
388 }
389}
390
391pub 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 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 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 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 let color = heatmap.interpolate_color(5.0, 5.0, 5.0);
584
585 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 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 assert!(heatmap.is_dark_color(&Color::Gray(0.3)));
643 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 assert!(heatmap.is_dark_color(&Color::Cmyk(0.0, 0.0, 0.0, 1.0)));
654 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 let span = heatmap.get_span();
679 assert_eq!(span.columns, 6);
680
681 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), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0), ],
719 min_value: None,
720 max_value: None,
721 };
722 let heatmap = HeatMap::new(data).with_color_scale(color_scale);
723
724 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 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 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 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 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 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 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}