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 ScatterPlot {
17 config: ComponentConfig,
19 data: Vec<ScatterPoint>,
21 options: ScatterPlotOptions,
23}
24
25impl ScatterPlot {
26 pub fn new(data: Vec<ScatterPoint>) -> Self {
28 Self {
29 config: ComponentConfig::new(ComponentSpan::new(6)), data,
31 options: ScatterPlotOptions::default(),
32 }
33 }
34
35 pub fn with_options(mut self, options: ScatterPlotOptions) -> Self {
37 self.options = options;
38 self
39 }
40
41 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 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 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 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 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 let (min_x, max_x, min_y, max_y) = self.get_bounds();
110
111 page.graphics()
113 .set_fill_color(Color::white())
114 .rect(plot_x, plot_y, plot_width, plot_height)
115 .fill();
116
117 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 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 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 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 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 for i in 0..=num_grid_lines {
177 let t = i as f64 / num_grid_lines as f64;
178
179 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 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 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 page.graphics()
210 .set_fill_color(color)
211 .circle(px, py, size)
212 .fill();
213
214 page.graphics()
216 .set_stroke_color(Color::white())
217 .set_line_width(0.5)
218 .circle(px, py, size)
219 .stroke();
220 }
221
222 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#[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#[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
279pub 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 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 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 let result = plot.map_to_plot(50.0, 0.0, 100.0, 0.0, 200.0);
437 assert_eq!(result, 100.0);
438
439 let result = plot.map_to_plot(0.0, 0.0, 100.0, 100.0, 200.0);
441 assert_eq!(result, 100.0);
442
443 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 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 let span = plot.get_span();
464 assert_eq!(span.columns, 6);
465
466 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 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 let result = plot.map_to_plot(0.0, -100.0, 100.0, 0.0, 200.0);
527 assert_eq!(result, 100.0);
528
529 let result = plot.map_to_plot(-100.0, -100.0, 100.0, 0.0, 200.0);
531 assert_eq!(result, 0.0);
532 }
533}