Skip to main content

oxidize_pdf/dashboard/
scatter_plot.rs

1//! ScatterPlot Visualization Component
2//!
3//! This module implements scatter plots for showing correlations and distributions
4//! in two-dimensional data.
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/// ScatterPlot visualization component
15#[derive(Debug, Clone)]
16pub struct ScatterPlot {
17    /// Component configuration
18    config: ComponentConfig,
19    /// Scatter plot data
20    data: Vec<ScatterPoint>,
21    /// Configuration options
22    options: ScatterPlotOptions,
23}
24
25impl ScatterPlot {
26    /// Create a new scatter plot
27    pub fn new(data: Vec<ScatterPoint>) -> Self {
28        Self {
29            config: ComponentConfig::new(ComponentSpan::new(6)), // Half width by default
30            data,
31            options: ScatterPlotOptions::default(),
32        }
33    }
34
35    /// Set scatter plot options
36    pub fn with_options(mut self, options: ScatterPlotOptions) -> Self {
37        self.options = options;
38        self
39    }
40
41    /// Get data bounds (min/max for x and y)
42    fn get_bounds(&self) -> (f64, f64, f64, f64) {
43        if self.data.is_empty() {
44            return (0.0, 100.0, 0.0, 100.0);
45        }
46
47        let mut min_x = f64::INFINITY;
48        let mut max_x = f64::NEG_INFINITY;
49        let mut min_y = f64::INFINITY;
50        let mut max_y = f64::NEG_INFINITY;
51
52        for point in &self.data {
53            min_x = min_x.min(point.x);
54            max_x = max_x.max(point.x);
55            min_y = min_y.min(point.y);
56            max_y = max_y.max(point.y);
57        }
58
59        // Add 10% padding
60        let x_range = max_x - min_x;
61        let y_range = max_y - min_y;
62        let x_padding = x_range * 0.1;
63        let y_padding = y_range * 0.1;
64
65        (
66            min_x - x_padding,
67            max_x + x_padding,
68            min_y - y_padding,
69            max_y + y_padding,
70        )
71    }
72
73    /// Map data coordinate to plot coordinate
74    fn map_to_plot(&self, value: f64, min: f64, max: f64, plot_min: f64, plot_max: f64) -> f64 {
75        if max == min {
76            return (plot_min + plot_max) / 2.0;
77        }
78        plot_min + (value - min) / (max - min) * (plot_max - plot_min)
79    }
80}
81
82impl DashboardComponent for ScatterPlot {
83    fn render(
84        &self,
85        page: &mut Page,
86        position: ComponentPosition,
87        theme: &DashboardTheme,
88    ) -> Result<(), PdfError> {
89        let title = self.options.title.as_deref().unwrap_or("Scatter Plot");
90
91        // Calculate layout
92        let title_height = 30.0;
93        let axis_label_space = 40.0;
94        let margin = 10.0;
95
96        let plot_x = position.x + axis_label_space + margin;
97        let plot_y = position.y + axis_label_space;
98        let plot_width = position.width - axis_label_space - 2.0 * margin;
99        let plot_height = position.height - title_height - axis_label_space - margin;
100
101        // Render title
102        page.text()
103            .set_font(crate::Font::HelveticaBold, theme.typography.heading_size)
104            .set_fill_color(theme.colors.text_primary)
105            .at(position.x, position.y + position.height - 15.0)
106            .write(title)?;
107
108        // Get data bounds
109        let (min_x, max_x, min_y, max_y) = self.get_bounds();
110
111        // Draw plot background
112        page.graphics()
113            .set_fill_color(Color::white())
114            .rect(plot_x, plot_y, plot_width, plot_height)
115            .fill();
116
117        // Draw grid lines
118        let grid_color = Color::gray(0.9);
119        let num_grid_lines = 5;
120
121        for i in 0..=num_grid_lines {
122            let t = i as f64 / num_grid_lines as f64;
123
124            // Vertical grid lines
125            let x = plot_x + t * plot_width;
126            page.graphics()
127                .set_stroke_color(grid_color)
128                .set_line_width(0.5)
129                .move_to(x, plot_y)
130                .line_to(x, plot_y + plot_height)
131                .stroke();
132
133            // Horizontal grid lines
134            let y = plot_y + t * plot_height;
135            page.graphics()
136                .set_stroke_color(grid_color)
137                .set_line_width(0.5)
138                .move_to(plot_x, y)
139                .line_to(plot_x + plot_width, y)
140                .stroke();
141        }
142
143        // Draw axes
144        page.graphics()
145            .set_stroke_color(Color::black())
146            .set_line_width(1.5)
147            .move_to(plot_x, plot_y)
148            .line_to(plot_x, plot_y + plot_height)
149            .stroke();
150
151        page.graphics()
152            .set_stroke_color(Color::black())
153            .set_line_width(1.5)
154            .move_to(plot_x, plot_y)
155            .line_to(plot_x + plot_width, plot_y)
156            .stroke();
157
158        // Draw axis labels
159        if let Some(ref x_label) = self.options.x_label {
160            page.text()
161                .set_font(crate::Font::Helvetica, 10.0)
162                .set_fill_color(theme.colors.text_secondary)
163                .at(plot_x + plot_width / 2.0 - 20.0, plot_y - 25.0)
164                .write(x_label)?;
165        }
166
167        if let Some(ref y_label) = self.options.y_label {
168            page.text()
169                .set_font(crate::Font::Helvetica, 10.0)
170                .set_fill_color(theme.colors.text_secondary)
171                .at(position.x + 5.0, plot_y + plot_height / 2.0)
172                .write(y_label)?;
173        }
174
175        // Draw axis tick labels
176        for i in 0..=num_grid_lines {
177            let t = i as f64 / num_grid_lines as f64;
178
179            // X-axis labels
180            let x_value = min_x + t * (max_x - min_x);
181            let x_pos = plot_x + t * plot_width;
182            page.text()
183                .set_font(crate::Font::Helvetica, 8.0)
184                .set_fill_color(theme.colors.text_secondary)
185                .at(x_pos - 10.0, plot_y - 15.0)
186                .write(&format!("{:.1}", x_value))?;
187
188            // Y-axis labels
189            let y_value = min_y + t * (max_y - min_y);
190            let y_pos = plot_y + t * plot_height;
191            page.text()
192                .set_font(crate::Font::Helvetica, 8.0)
193                .set_fill_color(theme.colors.text_secondary)
194                .at(plot_x - 35.0, y_pos - 3.0)
195                .write(&format!("{:.1}", y_value))?;
196        }
197
198        // Draw data points
199        let default_color = Color::hex("#007bff");
200        let default_size = 3.0;
201
202        for point in &self.data {
203            let px = self.map_to_plot(point.x, min_x, max_x, plot_x, plot_x + plot_width);
204            let py = self.map_to_plot(point.y, min_y, max_y, plot_y, plot_y + plot_height);
205            let size = point.size.unwrap_or(default_size);
206            let color = point.color.unwrap_or(default_color);
207
208            // Draw point as filled circle
209            page.graphics()
210                .set_fill_color(color)
211                .circle(px, py, size)
212                .fill();
213
214            // Draw point border for visibility
215            page.graphics()
216                .set_stroke_color(Color::white())
217                .set_line_width(0.5)
218                .circle(px, py, size)
219                .stroke();
220        }
221
222        // Draw plot border
223        page.graphics()
224            .set_stroke_color(Color::black())
225            .set_line_width(1.0)
226            .rect(plot_x, plot_y, plot_width, plot_height)
227            .stroke();
228
229        Ok(())
230    }
231
232    fn get_span(&self) -> ComponentSpan {
233        self.config.span
234    }
235    fn set_span(&mut self, span: ComponentSpan) {
236        self.config.span = span;
237    }
238    fn preferred_height(&self, _available_width: f64) -> f64 {
239        300.0
240    }
241    fn component_type(&self) -> &'static str {
242        "ScatterPlot"
243    }
244    fn complexity_score(&self) -> u8 {
245        60
246    }
247}
248
249/// Scatter plot data point
250#[derive(Debug, Clone)]
251pub struct ScatterPoint {
252    pub x: f64,
253    pub y: f64,
254    pub size: Option<f64>,
255    pub color: Option<Color>,
256    pub label: Option<String>,
257}
258
259/// Scatter plot options
260#[derive(Debug, Clone)]
261pub struct ScatterPlotOptions {
262    pub title: Option<String>,
263    pub x_label: Option<String>,
264    pub y_label: Option<String>,
265    pub show_trend_line: bool,
266}
267
268impl Default for ScatterPlotOptions {
269    fn default() -> Self {
270        Self {
271            title: None,
272            x_label: None,
273            y_label: None,
274            show_trend_line: false,
275        }
276    }
277}
278
279/// Builder for ScatterPlot
280pub struct ScatterPlotBuilder;
281
282impl ScatterPlotBuilder {
283    pub fn new() -> Self {
284        Self
285    }
286    pub fn build(self) -> ScatterPlot {
287        ScatterPlot::new(vec![])
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn sample_scatter_data() -> Vec<ScatterPoint> {
296        vec![
297            ScatterPoint {
298                x: 1.0,
299                y: 2.0,
300                size: None,
301                color: None,
302                label: Some("Point A".to_string()),
303            },
304            ScatterPoint {
305                x: 3.0,
306                y: 4.0,
307                size: Some(5.0),
308                color: Some(Color::rgb(1.0, 0.0, 0.0)),
309                label: None,
310            },
311            ScatterPoint {
312                x: 5.0,
313                y: 6.0,
314                size: None,
315                color: None,
316                label: None,
317            },
318        ]
319    }
320
321    #[test]
322    fn test_scatter_plot_new() {
323        let data = sample_scatter_data();
324        let plot = ScatterPlot::new(data.clone());
325
326        assert_eq!(plot.data.len(), 3);
327        assert_eq!(plot.data[0].x, 1.0);
328        assert_eq!(plot.data[0].y, 2.0);
329    }
330
331    #[test]
332    fn test_scatter_plot_with_options() {
333        let data = sample_scatter_data();
334        let options = ScatterPlotOptions {
335            title: Some("Test Plot".to_string()),
336            x_label: Some("X Axis".to_string()),
337            y_label: Some("Y Axis".to_string()),
338            show_trend_line: true,
339        };
340
341        let plot = ScatterPlot::new(data).with_options(options);
342
343        assert_eq!(plot.options.title, Some("Test Plot".to_string()));
344        assert_eq!(plot.options.x_label, Some("X Axis".to_string()));
345        assert_eq!(plot.options.y_label, Some("Y Axis".to_string()));
346        assert!(plot.options.show_trend_line);
347    }
348
349    #[test]
350    fn test_scatter_plot_options_default() {
351        let options = ScatterPlotOptions::default();
352
353        assert!(options.title.is_none());
354        assert!(options.x_label.is_none());
355        assert!(options.y_label.is_none());
356        assert!(!options.show_trend_line);
357    }
358
359    #[test]
360    fn test_scatter_plot_builder() {
361        let builder = ScatterPlotBuilder::new();
362        let plot = builder.build();
363
364        assert!(plot.data.is_empty());
365    }
366
367    #[test]
368    fn test_scatter_point_creation() {
369        let point = ScatterPoint {
370            x: 10.0,
371            y: 20.0,
372            size: Some(4.0),
373            color: Some(Color::rgb(0.0, 1.0, 0.0)),
374            label: Some("Test".to_string()),
375        };
376
377        assert_eq!(point.x, 10.0);
378        assert_eq!(point.y, 20.0);
379        assert_eq!(point.size, Some(4.0));
380        assert!(point.color.is_some());
381        assert_eq!(point.label, Some("Test".to_string()));
382    }
383
384    #[test]
385    fn test_get_bounds_with_data() {
386        let data = sample_scatter_data();
387        let plot = ScatterPlot::new(data);
388
389        let (min_x, max_x, min_y, max_y) = plot.get_bounds();
390
391        // Original bounds are (1,5) for x and (2,6) for y
392        // With 10% padding: x_range=4, y_range=4, padding=0.4
393        assert!(min_x < 1.0);
394        assert!(max_x > 5.0);
395        assert!(min_y < 2.0);
396        assert!(max_y > 6.0);
397    }
398
399    #[test]
400    fn test_get_bounds_empty_data() {
401        let plot = ScatterPlot::new(vec![]);
402
403        let (min_x, max_x, min_y, max_y) = plot.get_bounds();
404
405        assert_eq!(min_x, 0.0);
406        assert_eq!(max_x, 100.0);
407        assert_eq!(min_y, 0.0);
408        assert_eq!(max_y, 100.0);
409    }
410
411    #[test]
412    fn test_get_bounds_single_point() {
413        let data = vec![ScatterPoint {
414            x: 5.0,
415            y: 5.0,
416            size: None,
417            color: None,
418            label: None,
419        }];
420        let plot = ScatterPlot::new(data);
421
422        let (min_x, max_x, min_y, max_y) = plot.get_bounds();
423
424        // Single point has range 0, so padding is 0
425        assert_eq!(min_x, 5.0);
426        assert_eq!(max_x, 5.0);
427        assert_eq!(min_y, 5.0);
428        assert_eq!(max_y, 5.0);
429    }
430
431    #[test]
432    fn test_map_to_plot_normal() {
433        let plot = ScatterPlot::new(vec![]);
434
435        // Map value 50 from range [0,100] to plot range [0,200]
436        let result = plot.map_to_plot(50.0, 0.0, 100.0, 0.0, 200.0);
437        assert_eq!(result, 100.0);
438
439        // Map value 0 from range [0,100] to plot range [100,200]
440        let result = plot.map_to_plot(0.0, 0.0, 100.0, 100.0, 200.0);
441        assert_eq!(result, 100.0);
442
443        // Map value 100 from range [0,100] to plot range [100,200]
444        let result = plot.map_to_plot(100.0, 0.0, 100.0, 100.0, 200.0);
445        assert_eq!(result, 200.0);
446    }
447
448    #[test]
449    fn test_map_to_plot_same_min_max() {
450        let plot = ScatterPlot::new(vec![]);
451
452        // When min == max, should return midpoint
453        let result = plot.map_to_plot(5.0, 5.0, 5.0, 0.0, 100.0);
454        assert_eq!(result, 50.0);
455    }
456
457    #[test]
458    fn test_component_span() {
459        let data = sample_scatter_data();
460        let mut plot = ScatterPlot::new(data);
461
462        // Default span
463        let span = plot.get_span();
464        assert_eq!(span.columns, 6);
465
466        // Set new span
467        plot.set_span(ComponentSpan::new(12));
468        assert_eq!(plot.get_span().columns, 12);
469    }
470
471    #[test]
472    fn test_component_type() {
473        let plot = ScatterPlot::new(vec![]);
474
475        assert_eq!(plot.component_type(), "ScatterPlot");
476    }
477
478    #[test]
479    fn test_complexity_score() {
480        let plot = ScatterPlot::new(vec![]);
481
482        assert_eq!(plot.complexity_score(), 60);
483    }
484
485    #[test]
486    fn test_preferred_height() {
487        let plot = ScatterPlot::new(vec![]);
488
489        assert_eq!(plot.preferred_height(1000.0), 300.0);
490    }
491
492    #[test]
493    fn test_get_bounds_negative_values() {
494        let data = vec![
495            ScatterPoint {
496                x: -10.0,
497                y: -5.0,
498                size: None,
499                color: None,
500                label: None,
501            },
502            ScatterPoint {
503                x: 10.0,
504                y: 5.0,
505                size: None,
506                color: None,
507                label: None,
508            },
509        ];
510        let plot = ScatterPlot::new(data);
511
512        let (min_x, max_x, min_y, max_y) = plot.get_bounds();
513
514        // Should include negative values with padding
515        assert!(min_x < -10.0);
516        assert!(max_x > 10.0);
517        assert!(min_y < -5.0);
518        assert!(max_y > 5.0);
519    }
520
521    #[test]
522    fn test_map_to_plot_negative_range() {
523        let plot = ScatterPlot::new(vec![]);
524
525        // Map value 0 from range [-100,100] to plot range [0,200]
526        let result = plot.map_to_plot(0.0, -100.0, 100.0, 0.0, 200.0);
527        assert_eq!(result, 100.0);
528
529        // Map value -100 from range [-100,100] to plot range [0,200]
530        let result = plot.map_to_plot(-100.0, -100.0, 100.0, 0.0, 200.0);
531        assert_eq!(result, 0.0);
532    }
533}