Skip to main content

oxidize_pdf/dashboard/
treemap.rs

1//! TreeMap Visualization Component
2//!
3//! This module implements tree maps for hierarchical data visualization,
4//! showing nested rectangles proportional to data values.
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/// TreeMap visualization component
15#[derive(Debug, Clone)]
16pub struct TreeMap {
17    /// Component configuration
18    config: ComponentConfig,
19    /// Tree map data
20    data: Vec<TreeMapNode>,
21    /// Configuration options
22    options: TreeMapOptions,
23}
24
25impl TreeMap {
26    /// Create a new tree map
27    pub fn new(data: Vec<TreeMapNode>) -> Self {
28        Self {
29            config: ComponentConfig::new(ComponentSpan::new(6)), // Half width by default
30            data,
31            options: TreeMapOptions::default(),
32        }
33    }
34
35    /// Set tree map options
36    pub fn with_options(mut self, options: TreeMapOptions) -> Self {
37        self.options = options;
38        self
39    }
40
41    /// Simple squarified treemap layout (recursive)
42    fn layout_nodes(
43        &self,
44        nodes: &[TreeMapNode],
45        x: f64,
46        y: f64,
47        width: f64,
48        height: f64,
49        rects: &mut Vec<(TreeMapNode, f64, f64, f64, f64)>,
50    ) {
51        if nodes.is_empty() || width <= 0.0 || height <= 0.0 {
52            return;
53        }
54
55        let total: f64 = nodes.iter().map(|n| n.value).sum();
56        if total <= 0.0 {
57            return;
58        }
59
60        let mut current_x = x;
61        let mut current_y = y;
62        let mut remaining_width = width;
63        let mut remaining_height = height;
64
65        for node in nodes {
66            let ratio = node.value / total;
67            let area = width * height * ratio;
68
69            // Decide whether to split horizontally or vertically
70            let (rect_width, rect_height) = if remaining_width > remaining_height {
71                // Split horizontally
72                let w = area / remaining_height;
73                (w.min(remaining_width), remaining_height)
74            } else {
75                // Split vertically
76                let h = area / remaining_width;
77                (remaining_width, h.min(remaining_height))
78            };
79
80            // Add padding
81            let padding = self.options.padding;
82            let rect_x = current_x + padding;
83            let rect_y = current_y + padding;
84            let rect_w = (rect_width - 2.0 * padding).max(0.0);
85            let rect_h = (rect_height - 2.0 * padding).max(0.0);
86
87            rects.push((node.clone(), rect_x, rect_y, rect_w, rect_h));
88
89            // Update position for next rectangle
90            if remaining_width > remaining_height {
91                current_x += rect_width;
92                remaining_width -= rect_width;
93            } else {
94                current_y += rect_height;
95                remaining_height -= rect_height;
96            }
97        }
98    }
99}
100
101impl DashboardComponent for TreeMap {
102    fn render(
103        &self,
104        page: &mut Page,
105        position: ComponentPosition,
106        theme: &DashboardTheme,
107    ) -> Result<(), PdfError> {
108        let title = self.options.title.as_deref().unwrap_or("TreeMap");
109
110        let title_height = 30.0;
111        let plot_x = position.x;
112        let plot_y = position.y;
113        let plot_width = position.width;
114        let plot_height = position.height - title_height;
115
116        // Render title
117        page.text()
118            .set_font(crate::Font::HelveticaBold, theme.typography.heading_size)
119            .set_fill_color(theme.colors.text_primary)
120            .at(position.x, position.y + position.height - 15.0)
121            .write(title)?;
122
123        // Calculate layout
124        let mut rects = Vec::new();
125        self.layout_nodes(
126            &self.data,
127            plot_x,
128            plot_y,
129            plot_width,
130            plot_height,
131            &mut rects,
132        );
133
134        // Default colors if not specified
135        let default_colors = vec![
136            Color::hex("#1f77b4"),
137            Color::hex("#ff7f0e"),
138            Color::hex("#2ca02c"),
139            Color::hex("#d62728"),
140            Color::hex("#9467bd"),
141            Color::hex("#8c564b"),
142            Color::hex("#e377c2"),
143            Color::hex("#7f7f7f"),
144            Color::hex("#bcbd22"),
145            Color::hex("#17becf"),
146        ];
147
148        // Render rectangles
149        for (idx, (node, x, y, w, h)) in rects.iter().enumerate() {
150            let color = node
151                .color
152                .unwrap_or(default_colors[idx % default_colors.len()]);
153
154            // Draw rectangle
155            page.graphics()
156                .set_fill_color(color)
157                .rect(*x, *y, *w, *h)
158                .fill();
159
160            // Draw border
161            page.graphics()
162                .set_stroke_color(Color::white())
163                .set_line_width(1.5)
164                .rect(*x, *y, *w, *h)
165                .stroke();
166
167            // Draw label if enabled and rectangle is large enough
168            if self.options.show_labels && *w > 40.0 && *h > 20.0 {
169                // Determine text color based on background
170                let text_color = if self.is_dark_color(&color) {
171                    Color::white()
172                } else {
173                    Color::black()
174                };
175
176                // Draw name
177                page.text()
178                    .set_font(crate::Font::HelveticaBold, 9.0)
179                    .set_fill_color(text_color)
180                    .at(x + 5.0, y + h - 15.0)
181                    .write(&node.name)?;
182
183                // Draw value
184                page.text()
185                    .set_font(crate::Font::Helvetica, 8.0)
186                    .set_fill_color(text_color)
187                    .at(x + 5.0, y + h - 28.0)
188                    .write(&format!("{:.0}", node.value))?;
189            }
190        }
191
192        Ok(())
193    }
194
195    fn get_span(&self) -> ComponentSpan {
196        self.config.span
197    }
198    fn set_span(&mut self, span: ComponentSpan) {
199        self.config.span = span;
200    }
201    fn preferred_height(&self, _available_width: f64) -> f64 {
202        250.0
203    }
204    fn component_type(&self) -> &'static str {
205        "TreeMap"
206    }
207    fn complexity_score(&self) -> u8 {
208        70
209    }
210}
211
212impl TreeMap {
213    /// Check if a color is dark (for text contrast)
214    fn is_dark_color(&self, color: &Color) -> bool {
215        let (r, g, b) = match color {
216            Color::Rgb(r, g, b) => (*r, *g, *b),
217            Color::Gray(v) => (*v, *v, *v),
218            Color::Cmyk(c, m, y, k) => {
219                let r = (1.0 - c) * (1.0 - k);
220                let g = (1.0 - m) * (1.0 - k);
221                let b = (1.0 - y) * (1.0 - k);
222                (r, g, b)
223            }
224        };
225        let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
226        luminance < 0.5
227    }
228}
229
230/// TreeMap node data
231#[derive(Debug, Clone)]
232pub struct TreeMapNode {
233    pub name: String,
234    pub value: f64,
235    pub color: Option<Color>,
236    pub children: Vec<TreeMapNode>,
237}
238
239/// TreeMap options
240#[derive(Debug, Clone)]
241pub struct TreeMapOptions {
242    pub title: Option<String>,
243    pub show_labels: bool,
244    pub padding: f64,
245}
246
247impl Default for TreeMapOptions {
248    fn default() -> Self {
249        Self {
250            title: None,
251            show_labels: true,
252            padding: 2.0,
253        }
254    }
255}
256
257/// Builder for TreeMap
258pub struct TreeMapBuilder;
259
260impl TreeMapBuilder {
261    pub fn new() -> Self {
262        Self
263    }
264    pub fn build(self) -> TreeMap {
265        TreeMap::new(vec![])
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn sample_treemap_data() -> Vec<TreeMapNode> {
274        vec![
275            TreeMapNode {
276                name: "Category A".to_string(),
277                value: 100.0,
278                color: None,
279                children: vec![],
280            },
281            TreeMapNode {
282                name: "Category B".to_string(),
283                value: 50.0,
284                color: Some(Color::rgb(0.0, 0.5, 1.0)),
285                children: vec![],
286            },
287            TreeMapNode {
288                name: "Category C".to_string(),
289                value: 30.0,
290                color: None,
291                children: vec![],
292            },
293        ]
294    }
295
296    #[test]
297    fn test_treemap_new() {
298        let data = sample_treemap_data();
299        let treemap = TreeMap::new(data.clone());
300
301        assert_eq!(treemap.data.len(), 3);
302        assert_eq!(treemap.data[0].name, "Category A");
303        assert_eq!(treemap.data[0].value, 100.0);
304    }
305
306    #[test]
307    fn test_treemap_with_options() {
308        let data = sample_treemap_data();
309        let options = TreeMapOptions {
310            title: Some("My TreeMap".to_string()),
311            show_labels: false,
312            padding: 5.0,
313        };
314
315        let treemap = TreeMap::new(data).with_options(options);
316
317        assert_eq!(treemap.options.title, Some("My TreeMap".to_string()));
318        assert!(!treemap.options.show_labels);
319        assert_eq!(treemap.options.padding, 5.0);
320    }
321
322    #[test]
323    fn test_treemap_options_default() {
324        let options = TreeMapOptions::default();
325
326        assert!(options.title.is_none());
327        assert!(options.show_labels);
328        assert_eq!(options.padding, 2.0);
329    }
330
331    #[test]
332    fn test_treemap_builder() {
333        let builder = TreeMapBuilder::new();
334        let treemap = builder.build();
335
336        assert!(treemap.data.is_empty());
337    }
338
339    #[test]
340    fn test_treemap_node_creation() {
341        let node = TreeMapNode {
342            name: "Test Node".to_string(),
343            value: 42.0,
344            color: Some(Color::rgb(1.0, 0.0, 0.0)),
345            children: vec![TreeMapNode {
346                name: "Child".to_string(),
347                value: 10.0,
348                color: None,
349                children: vec![],
350            }],
351        };
352
353        assert_eq!(node.name, "Test Node");
354        assert_eq!(node.value, 42.0);
355        assert!(node.color.is_some());
356        assert_eq!(node.children.len(), 1);
357        assert_eq!(node.children[0].name, "Child");
358    }
359
360    #[test]
361    fn test_layout_nodes_empty() {
362        let treemap = TreeMap::new(vec![]);
363        let mut rects = Vec::new();
364
365        treemap.layout_nodes(&[], 0.0, 0.0, 100.0, 100.0, &mut rects);
366
367        assert!(rects.is_empty());
368    }
369
370    #[test]
371    fn test_layout_nodes_single() {
372        let data = vec![TreeMapNode {
373            name: "Single".to_string(),
374            value: 100.0,
375            color: None,
376            children: vec![],
377        }];
378        let treemap = TreeMap::new(data.clone());
379        let mut rects = Vec::new();
380
381        treemap.layout_nodes(&data, 0.0, 0.0, 100.0, 100.0, &mut rects);
382
383        assert_eq!(rects.len(), 1);
384        assert_eq!(rects[0].0.name, "Single");
385    }
386
387    #[test]
388    fn test_layout_nodes_multiple() {
389        let data = sample_treemap_data();
390        let treemap = TreeMap::new(data.clone());
391        let mut rects = Vec::new();
392
393        treemap.layout_nodes(&data, 0.0, 0.0, 300.0, 200.0, &mut rects);
394
395        assert_eq!(rects.len(), 3);
396
397        // All rectangles should have positive dimensions
398        for (_, x, y, w, h) in &rects {
399            assert!(*x >= 0.0);
400            assert!(*y >= 0.0);
401            assert!(*w > 0.0);
402            assert!(*h > 0.0);
403        }
404    }
405
406    #[test]
407    fn test_layout_nodes_proportional() {
408        let data = vec![
409            TreeMapNode {
410                name: "A".to_string(),
411                value: 75.0,
412                color: None,
413                children: vec![],
414            },
415            TreeMapNode {
416                name: "B".to_string(),
417                value: 25.0,
418                color: None,
419                children: vec![],
420            },
421        ];
422        let treemap = TreeMap::new(data.clone());
423        let mut rects = Vec::new();
424
425        treemap.layout_nodes(&data, 0.0, 0.0, 100.0, 100.0, &mut rects);
426
427        // The larger node should have approximately 3x the area
428        let area_a = rects[0].3 * rects[0].4;
429        let area_b = rects[1].3 * rects[1].4;
430
431        // Due to padding, the ratio might not be exact
432        assert!(area_a > area_b);
433    }
434
435    #[test]
436    fn test_layout_nodes_zero_size() {
437        let data = sample_treemap_data();
438        let treemap = TreeMap::new(data.clone());
439        let mut rects = Vec::new();
440
441        // Zero width
442        treemap.layout_nodes(&data, 0.0, 0.0, 0.0, 100.0, &mut rects);
443        assert!(rects.is_empty());
444
445        // Zero height
446        rects.clear();
447        treemap.layout_nodes(&data, 0.0, 0.0, 100.0, 0.0, &mut rects);
448        assert!(rects.is_empty());
449    }
450
451    #[test]
452    fn test_layout_nodes_zero_total_value() {
453        let data = vec![
454            TreeMapNode {
455                name: "A".to_string(),
456                value: 0.0,
457                color: None,
458                children: vec![],
459            },
460            TreeMapNode {
461                name: "B".to_string(),
462                value: 0.0,
463                color: None,
464                children: vec![],
465            },
466        ];
467        let treemap = TreeMap::new(data.clone());
468        let mut rects = Vec::new();
469
470        treemap.layout_nodes(&data, 0.0, 0.0, 100.0, 100.0, &mut rects);
471
472        // Should not produce any rectangles when total is zero
473        assert!(rects.is_empty());
474    }
475
476    #[test]
477    fn test_is_dark_color_with_black() {
478        let treemap = TreeMap::new(vec![]);
479
480        assert!(treemap.is_dark_color(&Color::rgb(0.0, 0.0, 0.0)));
481    }
482
483    #[test]
484    fn test_is_dark_color_with_white() {
485        let treemap = TreeMap::new(vec![]);
486
487        assert!(!treemap.is_dark_color(&Color::rgb(1.0, 1.0, 1.0)));
488    }
489
490    #[test]
491    fn test_is_dark_color_with_gray() {
492        let treemap = TreeMap::new(vec![]);
493
494        // Gray(0.3) has luminance 0.3, which is < 0.5
495        assert!(treemap.is_dark_color(&Color::Gray(0.3)));
496        // Gray(0.7) has luminance 0.7, which is > 0.5
497        assert!(!treemap.is_dark_color(&Color::Gray(0.7)));
498    }
499
500    #[test]
501    fn test_is_dark_color_with_cmyk() {
502        let treemap = TreeMap::new(vec![]);
503
504        // CMYK black (0, 0, 0, 1) -> RGB (0, 0, 0)
505        assert!(treemap.is_dark_color(&Color::Cmyk(0.0, 0.0, 0.0, 1.0)));
506        // CMYK white-ish (0, 0, 0, 0) -> RGB (1, 1, 1)
507        assert!(!treemap.is_dark_color(&Color::Cmyk(0.0, 0.0, 0.0, 0.0)));
508    }
509
510    #[test]
511    fn test_is_dark_color_with_primary_colors() {
512        let treemap = TreeMap::new(vec![]);
513
514        // Red: luminance = 0.299
515        assert!(treemap.is_dark_color(&Color::rgb(1.0, 0.0, 0.0)));
516        // Green: luminance = 0.587
517        assert!(!treemap.is_dark_color(&Color::rgb(0.0, 1.0, 0.0)));
518        // Blue: luminance = 0.114
519        assert!(treemap.is_dark_color(&Color::rgb(0.0, 0.0, 1.0)));
520    }
521
522    #[test]
523    fn test_component_span() {
524        let data = sample_treemap_data();
525        let mut treemap = TreeMap::new(data);
526
527        // Default span
528        let span = treemap.get_span();
529        assert_eq!(span.columns, 6);
530
531        // Set new span
532        treemap.set_span(ComponentSpan::new(12));
533        assert_eq!(treemap.get_span().columns, 12);
534    }
535
536    #[test]
537    fn test_component_type() {
538        let treemap = TreeMap::new(vec![]);
539
540        assert_eq!(treemap.component_type(), "TreeMap");
541    }
542
543    #[test]
544    fn test_complexity_score() {
545        let treemap = TreeMap::new(vec![]);
546
547        assert_eq!(treemap.complexity_score(), 70);
548    }
549
550    #[test]
551    fn test_preferred_height() {
552        let treemap = TreeMap::new(vec![]);
553
554        assert_eq!(treemap.preferred_height(1000.0), 250.0);
555    }
556
557    #[test]
558    fn test_treemap_node_with_children() {
559        let data = vec![TreeMapNode {
560            name: "Parent".to_string(),
561            value: 100.0,
562            color: None,
563            children: vec![
564                TreeMapNode {
565                    name: "Child 1".to_string(),
566                    value: 60.0,
567                    color: None,
568                    children: vec![],
569                },
570                TreeMapNode {
571                    name: "Child 2".to_string(),
572                    value: 40.0,
573                    color: None,
574                    children: vec![],
575                },
576            ],
577        }];
578
579        let treemap = TreeMap::new(data.clone());
580
581        assert_eq!(treemap.data[0].children.len(), 2);
582        assert_eq!(treemap.data[0].children[0].value, 60.0);
583        assert_eq!(treemap.data[0].children[1].value, 40.0);
584    }
585
586    #[test]
587    fn test_layout_nodes_with_wide_rectangle() {
588        let data = vec![
589            TreeMapNode {
590                name: "A".to_string(),
591                value: 50.0,
592                color: None,
593                children: vec![],
594            },
595            TreeMapNode {
596                name: "B".to_string(),
597                value: 50.0,
598                color: None,
599                children: vec![],
600            },
601        ];
602        let treemap = TreeMap::new(data.clone());
603        let mut rects = Vec::new();
604
605        // Wide rectangle (width > height) should split horizontally
606        treemap.layout_nodes(&data, 0.0, 0.0, 200.0, 50.0, &mut rects);
607
608        assert_eq!(rects.len(), 2);
609    }
610
611    #[test]
612    fn test_layout_nodes_with_tall_rectangle() {
613        let data = vec![
614            TreeMapNode {
615                name: "A".to_string(),
616                value: 50.0,
617                color: None,
618                children: vec![],
619            },
620            TreeMapNode {
621                name: "B".to_string(),
622                value: 50.0,
623                color: None,
624                children: vec![],
625            },
626        ];
627        let treemap = TreeMap::new(data.clone());
628        let mut rects = Vec::new();
629
630        // Tall rectangle (height > width) should split vertically
631        treemap.layout_nodes(&data, 0.0, 0.0, 50.0, 200.0, &mut rects);
632
633        assert_eq!(rects.len(), 2);
634    }
635
636    #[test]
637    fn test_layout_respects_padding() {
638        let data = vec![TreeMapNode {
639            name: "Single".to_string(),
640            value: 100.0,
641            color: None,
642            children: vec![],
643        }];
644
645        let options = TreeMapOptions {
646            title: None,
647            show_labels: true,
648            padding: 10.0,
649        };
650        let treemap = TreeMap::new(data.clone()).with_options(options);
651        let mut rects = Vec::new();
652
653        treemap.layout_nodes(&data, 0.0, 0.0, 100.0, 100.0, &mut rects);
654
655        // With padding of 10.0, the rectangle should start at (10, 10)
656        // and have dimensions reduced by 2*10 = 20
657        let (_, x, y, w, h) = &rects[0];
658        assert!((*x - 10.0).abs() < 0.01);
659        assert!((*y - 10.0).abs() < 0.01);
660        assert!((*w - 80.0).abs() < 0.01);
661        assert!((*h - 80.0).abs() < 0.01);
662    }
663}