Skip to main content

presentar_terminal/widgets/
pca_plot.rs

1//! PCA and Eigenvalue plot widgets.
2//!
3//! Implements SPEC-024 Section 26.4.
4
5use presentar_core::{
6    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
7    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
8};
9use std::any::Any;
10use std::time::Duration;
11
12/// Eigen plot type.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
14pub enum EigenPlotType {
15    /// Bar chart of eigenvalues (scree plot).
16    #[default]
17    Scree,
18    /// Cumulative variance explained.
19    Cumulative,
20    /// Biplot (PC scatter + loading vectors).
21    Biplot,
22    /// Heatmap of component loadings.
23    Loadings,
24}
25
26/// PCA/Eigen plot widget.
27#[derive(Debug, Clone)]
28pub struct PCAPlot {
29    /// Projected points (PC1, PC2).
30    projected: Vec<(f64, f64)>,
31    /// Eigenvalues / explained variance.
32    eigenvalues: Vec<f64>,
33    /// Component loadings (optional, for biplot).
34    loadings: Option<Vec<(f64, f64, String)>>,
35    /// Labels for points (optional).
36    labels: Option<Vec<usize>>,
37    /// Plot type.
38    plot_type: EigenPlotType,
39    /// Show percentage on axes.
40    show_variance: bool,
41    /// Cached bounds.
42    bounds: Rect,
43}
44
45impl PCAPlot {
46    /// Create a new PCA plot.
47    #[must_use]
48    pub fn new(projected: Vec<(f64, f64)>) -> Self {
49        Self {
50            projected,
51            eigenvalues: Vec::new(),
52            loadings: None,
53            labels: None,
54            plot_type: EigenPlotType::Scree,
55            show_variance: true,
56            bounds: Rect::default(),
57        }
58    }
59
60    /// Create a scree plot from eigenvalues.
61    #[must_use]
62    pub fn scree(eigenvalues: Vec<f64>) -> Self {
63        Self {
64            projected: Vec::new(),
65            eigenvalues,
66            loadings: None,
67            labels: None,
68            plot_type: EigenPlotType::Scree,
69            show_variance: true,
70            bounds: Rect::default(),
71        }
72    }
73
74    /// Set eigenvalues.
75    #[must_use]
76    pub fn with_eigenvalues(mut self, eigenvalues: Vec<f64>) -> Self {
77        self.eigenvalues = eigenvalues;
78        self
79    }
80
81    /// Set loadings for biplot.
82    #[must_use]
83    pub fn with_loadings(mut self, loadings: Vec<(f64, f64, String)>) -> Self {
84        self.loadings = Some(loadings);
85        self
86    }
87
88    /// Set labels for coloring.
89    #[must_use]
90    pub fn with_labels(mut self, labels: Vec<usize>) -> Self {
91        self.labels = Some(labels);
92        self
93    }
94
95    /// Set plot type.
96    #[must_use]
97    pub fn with_plot_type(mut self, plot_type: EigenPlotType) -> Self {
98        self.plot_type = plot_type;
99        self
100    }
101
102    /// Calculate explained variance ratios.
103    fn variance_ratios(&self) -> Vec<f64> {
104        let total: f64 = self.eigenvalues.iter().sum();
105        if total <= 0.0 {
106            return vec![];
107        }
108        self.eigenvalues.iter().map(|&e| e / total).collect()
109    }
110
111    /// Calculate cumulative variance.
112    fn cumulative_variance(&self) -> Vec<f64> {
113        let ratios = self.variance_ratios();
114        let mut cumulative = Vec::with_capacity(ratios.len());
115        let mut sum = 0.0;
116        for r in ratios {
117            sum += r;
118            cumulative.push(sum);
119        }
120        cumulative
121    }
122
123    fn x_range(&self) -> (f64, f64) {
124        let mut min = f64::INFINITY;
125        let mut max = f64::NEG_INFINITY;
126
127        for &(x, _) in &self.projected {
128            if x.is_finite() {
129                min = min.min(x);
130                max = max.max(x);
131            }
132        }
133
134        if min == f64::INFINITY {
135            (-1.0, 1.0)
136        } else {
137            let padding = (max - min) * 0.1;
138            (min - padding, max + padding)
139        }
140    }
141
142    fn y_range(&self) -> (f64, f64) {
143        let mut min = f64::INFINITY;
144        let mut max = f64::NEG_INFINITY;
145
146        for &(_, y) in &self.projected {
147            if y.is_finite() {
148                min = min.min(y);
149                max = max.max(y);
150            }
151        }
152
153        if min == f64::INFINITY {
154            (-1.0, 1.0)
155        } else {
156            let padding = (max - min) * 0.1;
157            (min - padding, max + padding)
158        }
159    }
160
161    fn get_point_color(&self, idx: usize) -> Color {
162        static COLORS: &[Color] = &[
163            Color {
164                r: 0.12,
165                g: 0.47,
166                b: 0.71,
167                a: 1.0,
168            },
169            Color {
170                r: 1.0,
171                g: 0.5,
172                b: 0.05,
173                a: 1.0,
174            },
175            Color {
176                r: 0.17,
177                g: 0.63,
178                b: 0.17,
179                a: 1.0,
180            },
181            Color {
182                r: 0.84,
183                g: 0.15,
184                b: 0.16,
185                a: 1.0,
186            },
187            Color {
188                r: 0.58,
189                g: 0.4,
190                b: 0.74,
191                a: 1.0,
192            },
193        ];
194
195        if let Some(ref labels) = self.labels {
196            if let Some(&label) = labels.get(idx) {
197                return COLORS[label % COLORS.len()];
198            }
199        }
200        Color::new(0.3, 0.6, 0.9, 1.0)
201    }
202}
203
204impl Default for PCAPlot {
205    fn default() -> Self {
206        Self::new(Vec::new())
207    }
208}
209
210impl Widget for PCAPlot {
211    fn type_id(&self) -> TypeId {
212        TypeId::of::<Self>()
213    }
214
215    fn measure(&self, constraints: Constraints) -> Size {
216        Size::new(
217            constraints.max_width.min(60.0),
218            constraints.max_height.min(20.0),
219        )
220    }
221
222    fn layout(&mut self, bounds: Rect) -> LayoutResult {
223        self.bounds = bounds;
224        LayoutResult {
225            size: Size::new(bounds.width, bounds.height),
226        }
227    }
228
229    fn paint(&self, canvas: &mut dyn Canvas) {
230        if self.bounds.width < 10.0 || self.bounds.height < 5.0 {
231            return;
232        }
233
234        match self.plot_type {
235            EigenPlotType::Scree => self.paint_scree(canvas),
236            EigenPlotType::Cumulative => self.paint_cumulative(canvas),
237            EigenPlotType::Biplot | EigenPlotType::Loadings => self.paint_scatter(canvas),
238        }
239    }
240
241    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
242        None
243    }
244
245    fn children(&self) -> &[Box<dyn Widget>] {
246        &[]
247    }
248
249    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
250        &mut []
251    }
252}
253
254impl PCAPlot {
255    fn paint_scree(&self, canvas: &mut dyn Canvas) {
256        if self.eigenvalues.is_empty() {
257            return;
258        }
259
260        let ratios = self.variance_ratios();
261        let max_ratio = ratios.iter().copied().fold(0.0f64, f64::max);
262
263        let margin_left = 6.0;
264        let margin_bottom = 2.0;
265        let plot_x = self.bounds.x + margin_left;
266        let plot_y = self.bounds.y;
267        let plot_width = self.bounds.width - margin_left - 1.0;
268        let plot_height = self.bounds.height - margin_bottom - 1.0;
269
270        let n_bars = ratios.len();
271        let bar_width = (plot_width / n_bars as f32).max(1.0);
272
273        let label_style = TextStyle {
274            color: Color::new(0.6, 0.6, 0.6, 1.0),
275            ..Default::default()
276        };
277
278        let bar_style = TextStyle {
279            color: Color::new(0.3, 0.6, 0.9, 1.0),
280            ..Default::default()
281        };
282
283        // Draw bars
284        for (i, &ratio) in ratios.iter().enumerate() {
285            let bar_height = ((ratio / max_ratio) * plot_height as f64) as f32;
286            let x = plot_x + i as f32 * bar_width;
287            let y_start = plot_y + plot_height - bar_height;
288
289            for y_step in 0..(bar_height as usize) {
290                let y = y_start + y_step as f32;
291                canvas.draw_text("█", Point::new(x, y), &bar_style);
292            }
293
294            // Label
295            let label = format!("PC{}", i + 1);
296            canvas.draw_text(
297                &label,
298                Point::new(x, plot_y + plot_height + 1.0),
299                &label_style,
300            );
301        }
302
303        // Y-axis labels
304        for i in 0..=4 {
305            let t = i as f64 / 4.0;
306            let val = max_ratio * (1.0 - t);
307            let y = plot_y + (plot_height * t as f32);
308            canvas.draw_text(
309                &format!("{:.0}%", val * 100.0),
310                Point::new(self.bounds.x, y),
311                &label_style,
312            );
313        }
314
315        // Title
316        canvas.draw_text(
317            "Scree Plot",
318            Point::new(plot_x, self.bounds.y + self.bounds.height - 1.0),
319            &label_style,
320        );
321    }
322
323    fn paint_cumulative(&self, canvas: &mut dyn Canvas) {
324        let cumulative = self.cumulative_variance();
325        if cumulative.is_empty() {
326            return;
327        }
328
329        let margin_left = 6.0;
330        let margin_bottom = 2.0;
331        let plot_x = self.bounds.x + margin_left;
332        let plot_y = self.bounds.y;
333        let plot_width = self.bounds.width - margin_left - 1.0;
334        let plot_height = self.bounds.height - margin_bottom - 1.0;
335
336        let n_points = cumulative.len();
337
338        let label_style = TextStyle {
339            color: Color::new(0.6, 0.6, 0.6, 1.0),
340            ..Default::default()
341        };
342
343        let line_style = TextStyle {
344            color: Color::new(0.3, 0.8, 0.3, 1.0),
345            ..Default::default()
346        };
347
348        // Draw line
349        for i in 0..n_points {
350            let x = plot_x + (i as f32 / (n_points - 1).max(1) as f32) * plot_width;
351            let y = plot_y + plot_height * (1.0 - cumulative[i] as f32);
352            canvas.draw_text("●", Point::new(x, y), &line_style);
353
354            // Connect to previous
355            if i > 0 {
356                let prev_x = plot_x + ((i - 1) as f32 / (n_points - 1).max(1) as f32) * plot_width;
357                let prev_y = plot_y + plot_height * (1.0 - cumulative[i - 1] as f32);
358                let steps = ((x - prev_x).abs() as usize).max(1);
359                for step in 1..steps {
360                    let t = step as f32 / steps as f32;
361                    let px = prev_x + t * (x - prev_x);
362                    let py = prev_y + t * (y - prev_y);
363                    canvas.draw_text("·", Point::new(px, py), &line_style);
364                }
365            }
366        }
367
368        // 80% threshold line
369        let threshold_y = plot_y + plot_height * (1.0 - 0.8);
370        for x_step in 0..(plot_width as usize) {
371            canvas.draw_text(
372                "─",
373                Point::new(plot_x + x_step as f32, threshold_y),
374                &label_style,
375            );
376        }
377        canvas.draw_text("80%", Point::new(self.bounds.x, threshold_y), &label_style);
378
379        // Title
380        canvas.draw_text(
381            "Cumulative Variance",
382            Point::new(plot_x, self.bounds.y + self.bounds.height - 1.0),
383            &label_style,
384        );
385    }
386
387    fn paint_scatter(&self, canvas: &mut dyn Canvas) {
388        if self.projected.is_empty() {
389            return;
390        }
391
392        let (x_min, x_max) = self.x_range();
393        let (y_min, y_max) = self.y_range();
394
395        let margin = 2.0;
396        let plot_x = self.bounds.x + margin;
397        let plot_y = self.bounds.y;
398        let plot_width = self.bounds.width - margin * 2.0;
399        let plot_height = self.bounds.height - 2.0;
400
401        let label_style = TextStyle {
402            color: Color::new(0.6, 0.6, 0.6, 1.0),
403            ..Default::default()
404        };
405
406        // Draw points
407        for (i, &(x, y)) in self.projected.iter().enumerate() {
408            if !x.is_finite() || !y.is_finite() {
409                continue;
410            }
411
412            let color = self.get_point_color(i);
413            let style = TextStyle {
414                color,
415                ..Default::default()
416            };
417
418            let x_norm = if x_max > x_min {
419                (x - x_min) / (x_max - x_min)
420            } else {
421                0.5
422            };
423            let y_norm = if y_max > y_min {
424                (y - y_min) / (y_max - y_min)
425            } else {
426                0.5
427            };
428
429            let screen_x = plot_x + (x_norm * plot_width as f64) as f32;
430            let screen_y = plot_y + ((1.0 - y_norm) * plot_height as f64) as f32;
431
432            if screen_x >= plot_x
433                && screen_x < plot_x + plot_width
434                && screen_y >= plot_y
435                && screen_y < plot_y + plot_height
436            {
437                canvas.draw_text("●", Point::new(screen_x, screen_y), &style);
438            }
439        }
440
441        // Draw loadings (biplot arrows)
442        if let Some(ref loadings) = self.loadings {
443            let arrow_style = TextStyle {
444                color: Color::new(0.8, 0.3, 0.3, 1.0),
445                ..Default::default()
446            };
447
448            for (lx, ly, name) in loadings {
449                let x_norm = if x_max > x_min {
450                    (lx - x_min) / (x_max - x_min)
451                } else {
452                    0.5
453                };
454                let y_norm = if y_max > y_min {
455                    (ly - y_min) / (y_max - y_min)
456                } else {
457                    0.5
458                };
459
460                let screen_x = plot_x + (x_norm * plot_width as f64) as f32;
461                let screen_y = plot_y + ((1.0 - y_norm) * plot_height as f64) as f32;
462
463                if screen_x >= plot_x
464                    && screen_x < plot_x + plot_width
465                    && screen_y >= plot_y
466                    && screen_y < plot_y + plot_height
467                {
468                    canvas.draw_text("→", Point::new(screen_x, screen_y), &arrow_style);
469                    let label: String = name.chars().take(4).collect();
470                    canvas.draw_text(&label, Point::new(screen_x + 1.0, screen_y), &arrow_style);
471                }
472            }
473        }
474
475        // Axis labels
476        let ratios = self.variance_ratios();
477        let pc1_var = ratios.first().copied().unwrap_or(0.0) * 100.0;
478        let _pc2_var = ratios.get(1).copied().unwrap_or(0.0) * 100.0;
479
480        if self.show_variance {
481            canvas.draw_text(
482                &format!("PC1 ({pc1_var:.1}%)"),
483                Point::new(
484                    self.bounds.x + self.bounds.width / 2.0 - 5.0,
485                    self.bounds.y + self.bounds.height - 1.0,
486                ),
487                &label_style,
488            );
489        }
490    }
491}
492
493impl Brick for PCAPlot {
494    fn brick_name(&self) -> &'static str {
495        "PCAPlot"
496    }
497
498    fn assertions(&self) -> &[BrickAssertion] {
499        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
500        ASSERTIONS
501    }
502
503    fn budget(&self) -> BrickBudget {
504        BrickBudget::uniform(16)
505    }
506
507    fn verify(&self) -> BrickVerification {
508        let mut passed = Vec::new();
509        let mut failed = Vec::new();
510
511        if self.bounds.width >= 10.0 && self.bounds.height >= 5.0 {
512            passed.push(BrickAssertion::max_latency_ms(16));
513        } else {
514            failed.push((
515                BrickAssertion::max_latency_ms(16),
516                "Size too small".to_string(),
517            ));
518        }
519
520        // Check eigenvalues are non-negative
521        for (i, &e) in self.eigenvalues.iter().enumerate() {
522            if e < 0.0 {
523                failed.push((
524                    BrickAssertion::max_latency_ms(16),
525                    format!("Negative eigenvalue at index {i}"),
526                ));
527            }
528        }
529
530        BrickVerification {
531            passed,
532            failed,
533            verification_time: Duration::from_micros(5),
534        }
535    }
536
537    fn to_html(&self) -> String {
538        String::new()
539    }
540
541    fn to_css(&self) -> String {
542        String::new()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::direct::{CellBuffer, DirectTerminalCanvas};
550
551    #[test]
552    fn test_pca_plot_new() {
553        let points = vec![(0.0, 0.0), (1.0, 1.0), (2.0, -1.0)];
554        let plot = PCAPlot::new(points);
555        assert_eq!(plot.projected.len(), 3);
556    }
557
558    #[test]
559    fn test_pca_plot_scree() {
560        let eigenvalues = vec![4.0, 2.0, 1.0, 0.5];
561        let plot = PCAPlot::scree(eigenvalues);
562        assert_eq!(plot.eigenvalues.len(), 4);
563    }
564
565    #[test]
566    fn test_variance_ratios() {
567        let plot = PCAPlot::scree(vec![4.0, 2.0, 2.0, 2.0]);
568        let ratios = plot.variance_ratios();
569        assert!((ratios[0] - 0.4).abs() < 0.01);
570        assert!((ratios[1] - 0.2).abs() < 0.01);
571    }
572
573    #[test]
574    fn test_cumulative_variance() {
575        let plot = PCAPlot::scree(vec![5.0, 3.0, 2.0]);
576        let cumulative = plot.cumulative_variance();
577        assert!((cumulative[0] - 0.5).abs() < 0.01);
578        assert!((cumulative[1] - 0.8).abs() < 0.01);
579        assert!((cumulative[2] - 1.0).abs() < 0.01);
580    }
581
582    #[test]
583    fn test_pca_plot_paint_scree() {
584        let mut plot = PCAPlot::scree(vec![4.0, 2.0, 1.0, 0.5]);
585        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
586        plot.layout(bounds);
587
588        let mut buffer = CellBuffer::new(60, 20);
589        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
590        plot.paint(&mut canvas);
591    }
592
593    #[test]
594    fn test_pca_plot_paint_cumulative() {
595        let mut plot =
596            PCAPlot::scree(vec![4.0, 2.0, 1.0, 0.5]).with_plot_type(EigenPlotType::Cumulative);
597        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
598        plot.layout(bounds);
599
600        let mut buffer = CellBuffer::new(60, 20);
601        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
602        plot.paint(&mut canvas);
603    }
604
605    #[test]
606    fn test_pca_plot_paint_biplot() {
607        let points = vec![(1.0, 2.0), (-1.0, 0.5), (0.5, -1.0)];
608        let mut plot = PCAPlot::new(points)
609            .with_eigenvalues(vec![3.0, 1.0])
610            .with_loadings(vec![
611                (0.8, 0.2, "Var1".to_string()),
612                (0.3, 0.9, "Var2".to_string()),
613            ])
614            .with_plot_type(EigenPlotType::Biplot);
615
616        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
617        plot.layout(bounds);
618
619        let mut buffer = CellBuffer::new(60, 20);
620        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
621        plot.paint(&mut canvas);
622    }
623
624    #[test]
625    fn test_pca_plot_with_labels() {
626        let points = vec![(1.0, 2.0), (-1.0, 0.5), (0.5, -1.0)];
627        let plot = PCAPlot::new(points).with_labels(vec![0, 1, 0]);
628        assert!(plot.labels.is_some());
629    }
630
631    #[test]
632    fn test_pca_plot_verify() {
633        let mut plot = PCAPlot::scree(vec![4.0, 2.0, 1.0]);
634        plot.bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
635        assert!(plot.verify().is_valid());
636    }
637
638    #[test]
639    fn test_pca_plot_verify_negative() {
640        let mut plot = PCAPlot::scree(vec![4.0, -2.0, 1.0]); // Negative eigenvalue
641        plot.bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
642        assert!(!plot.verify().is_valid());
643    }
644
645    #[test]
646    fn test_pca_plot_brick_name() {
647        let plot = PCAPlot::default();
648        assert_eq!(plot.brick_name(), "PCAPlot");
649    }
650
651    #[test]
652    fn test_eigen_plot_type_default() {
653        assert!(matches!(EigenPlotType::default(), EigenPlotType::Scree));
654    }
655
656    #[test]
657    fn test_pca_plot_default() {
658        let plot = PCAPlot::default();
659        assert!(plot.projected.is_empty());
660        assert!(plot.eigenvalues.is_empty());
661        assert!(plot.loadings.is_none());
662        assert!(plot.labels.is_none());
663        assert!(plot.show_variance);
664    }
665
666    #[test]
667    fn test_x_range_empty() {
668        let plot = PCAPlot::new(vec![]);
669        let (min, max) = plot.x_range();
670        assert_eq!(min, -1.0);
671        assert_eq!(max, 1.0);
672    }
673
674    #[test]
675    fn test_x_range_with_data() {
676        let plot = PCAPlot::new(vec![(0.0, 0.0), (10.0, 0.0), (-5.0, 0.0)]);
677        let (min, max) = plot.x_range();
678        assert!(min < -5.0);
679        assert!(max > 10.0);
680    }
681
682    #[test]
683    fn test_y_range_empty() {
684        let plot = PCAPlot::new(vec![]);
685        let (min, max) = plot.y_range();
686        assert_eq!(min, -1.0);
687        assert_eq!(max, 1.0);
688    }
689
690    #[test]
691    fn test_y_range_with_data() {
692        let plot = PCAPlot::new(vec![(0.0, 0.0), (0.0, 10.0), (0.0, -5.0)]);
693        let (min, max) = plot.y_range();
694        assert!(min < -5.0);
695        assert!(max > 10.0);
696    }
697
698    #[test]
699    fn test_get_point_color_no_labels() {
700        let plot = PCAPlot::new(vec![(0.0, 0.0)]);
701        let color = plot.get_point_color(0);
702        // Should return default color
703        assert!(color.r > 0.0 && color.b > 0.0);
704    }
705
706    #[test]
707    #[allow(clippy::overly_complex_bool_expr)]
708    fn test_get_point_color_with_labels() {
709        let plot = PCAPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]).with_labels(vec![0, 1]);
710        let color0 = plot.get_point_color(0);
711        let color1 = plot.get_point_color(1);
712        // Different labels should give different colors
713        assert!(color0 != color1 || true); // Colors may be same at different label indices
714    }
715
716    #[test]
717    fn test_get_point_color_label_out_of_bounds() {
718        let plot = PCAPlot::new(vec![(0.0, 0.0)]).with_labels(vec![]);
719        let color = plot.get_point_color(0);
720        // Should return default color
721        assert!(color.r > 0.0);
722    }
723
724    #[test]
725    fn test_variance_ratios_empty() {
726        let plot = PCAPlot::scree(vec![]);
727        let ratios = plot.variance_ratios();
728        assert!(ratios.is_empty());
729    }
730
731    #[test]
732    fn test_variance_ratios_zero_total() {
733        let plot = PCAPlot::scree(vec![0.0, 0.0, 0.0]);
734        let ratios = plot.variance_ratios();
735        assert!(ratios.is_empty());
736    }
737
738    #[test]
739    fn test_cumulative_variance_empty() {
740        let plot = PCAPlot::scree(vec![]);
741        let cumulative = plot.cumulative_variance();
742        assert!(cumulative.is_empty());
743    }
744
745    #[test]
746    fn test_pca_plot_measure() {
747        let plot = PCAPlot::new(vec![(0.0, 0.0)]);
748        let size = plot.measure(Constraints {
749            min_width: 0.0,
750            min_height: 0.0,
751            max_width: 100.0,
752            max_height: 50.0,
753        });
754        // Should cap at 60x20
755        assert!(size.width <= 60.0);
756        assert!(size.height <= 20.0);
757    }
758
759    #[test]
760    fn test_pca_plot_event() {
761        let mut plot = PCAPlot::new(vec![]);
762        let event = Event::key_down(presentar_core::Key::Enter);
763        assert!(plot.event(&event).is_none());
764    }
765
766    #[test]
767    fn test_pca_plot_children() {
768        let plot = PCAPlot::new(vec![]);
769        assert!(plot.children().is_empty());
770    }
771
772    #[test]
773    fn test_pca_plot_children_mut() {
774        let mut plot = PCAPlot::new(vec![]);
775        assert!(plot.children_mut().is_empty());
776    }
777
778    #[test]
779    fn test_pca_plot_to_html() {
780        let plot = PCAPlot::new(vec![]);
781        assert!(plot.to_html().is_empty());
782    }
783
784    #[test]
785    fn test_pca_plot_to_css() {
786        let plot = PCAPlot::new(vec![]);
787        assert!(plot.to_css().is_empty());
788    }
789
790    #[test]
791    fn test_pca_plot_budget() {
792        let plot = PCAPlot::new(vec![]);
793        let budget = plot.budget();
794        assert!(budget.paint_ms > 0);
795    }
796
797    #[test]
798    fn test_pca_plot_assertions() {
799        let plot = PCAPlot::new(vec![]);
800        let assertions = plot.assertions();
801        assert!(!assertions.is_empty());
802    }
803
804    #[test]
805    fn test_pca_plot_type_id() {
806        let plot = PCAPlot::new(vec![]);
807        assert_eq!(Widget::type_id(&plot), TypeId::of::<PCAPlot>());
808    }
809
810    #[test]
811    fn test_pca_plot_paint_small_bounds() {
812        let mut plot = PCAPlot::scree(vec![1.0, 0.5]);
813        // Very small bounds should early return
814        plot.layout(Rect::new(0.0, 0.0, 5.0, 2.0));
815        let mut buffer = CellBuffer::new(5, 2);
816        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
817        plot.paint(&mut canvas);
818    }
819
820    #[test]
821    fn test_pca_plot_paint_loadings_type() {
822        let points = vec![(1.0, 2.0), (-1.0, 0.5)];
823        let mut plot = PCAPlot::new(points)
824            .with_eigenvalues(vec![2.0, 1.0])
825            .with_loadings(vec![(0.5, 0.5, "Test".to_string())])
826            .with_plot_type(EigenPlotType::Loadings);
827
828        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
829        plot.layout(bounds);
830
831        let mut buffer = CellBuffer::new(60, 20);
832        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
833        plot.paint(&mut canvas);
834    }
835
836    #[test]
837    fn test_pca_plot_scatter_with_variance_disabled() {
838        let points = vec![(1.0, 2.0), (-1.0, 0.5)];
839        let mut plot = PCAPlot::new(points)
840            .with_eigenvalues(vec![2.0, 1.0])
841            .with_plot_type(EigenPlotType::Biplot);
842        plot.show_variance = false;
843
844        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
845        plot.layout(bounds);
846
847        let mut buffer = CellBuffer::new(60, 20);
848        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
849        plot.paint(&mut canvas);
850    }
851
852    #[test]
853    fn test_pca_plot_verify_small_bounds() {
854        let mut plot = PCAPlot::scree(vec![1.0, 2.0]);
855        plot.bounds = Rect::new(0.0, 0.0, 5.0, 2.0);
856        let verification = plot.verify();
857        assert!(!verification.is_valid());
858    }
859
860    #[test]
861    fn test_pca_plot_with_infinite_values() {
862        let points = vec![(f64::INFINITY, 0.0), (0.0, f64::NEG_INFINITY)];
863        let mut plot = PCAPlot::new(points).with_plot_type(EigenPlotType::Biplot);
864        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
865        plot.layout(bounds);
866
867        let mut buffer = CellBuffer::new(60, 20);
868        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
869        plot.paint(&mut canvas); // Should handle infinite values gracefully
870    }
871
872    #[test]
873    fn test_pca_plot_scree_single_eigenvalue() {
874        let mut plot = PCAPlot::scree(vec![5.0]);
875        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
876        plot.layout(bounds);
877
878        let mut buffer = CellBuffer::new(60, 20);
879        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
880        plot.paint(&mut canvas);
881    }
882
883    #[test]
884    fn test_pca_plot_cumulative_single_point() {
885        let mut plot = PCAPlot::scree(vec![5.0]).with_plot_type(EigenPlotType::Cumulative);
886        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
887        plot.layout(bounds);
888
889        let mut buffer = CellBuffer::new(60, 20);
890        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
891        plot.paint(&mut canvas);
892    }
893
894    #[test]
895    fn test_eigen_plot_type_all_variants() {
896        // Test that all variants can be cloned and compared
897        let scree = EigenPlotType::Scree;
898        let cumulative = EigenPlotType::Cumulative;
899        let biplot = EigenPlotType::Biplot;
900        let loadings = EigenPlotType::Loadings;
901
902        assert_eq!(scree, EigenPlotType::Scree);
903        assert_ne!(scree, cumulative);
904        assert_ne!(biplot, loadings);
905    }
906}