Skip to main content

presentar_terminal/widgets/
parallel_coords.rs

1//! Parallel coordinates plot widget.
2//!
3//! Implements SPEC-024 Section 26.6.3.
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/// Parallel coordinates plot widget.
13#[derive(Debug, Clone)]
14pub struct ParallelCoordinates {
15    /// Column names.
16    columns: Vec<String>,
17    /// Data rows (each row has one value per column).
18    data: Vec<Vec<f64>>,
19    /// Optional color-by values (one per row).
20    color_by: Option<Vec<f64>>,
21    /// Line alpha (0.0-1.0).
22    alpha: f32,
23    /// Show column labels.
24    show_labels: bool,
25    /// Cached bounds.
26    bounds: Rect,
27}
28
29impl ParallelCoordinates {
30    /// Create a new parallel coordinates plot.
31    #[must_use]
32    pub fn new(columns: Vec<String>, data: Vec<Vec<f64>>) -> Self {
33        Self {
34            columns,
35            data,
36            color_by: None,
37            alpha: 0.5,
38            show_labels: true,
39            bounds: Rect::default(),
40        }
41    }
42
43    /// Set color-by values.
44    #[must_use]
45    pub fn with_color_by(mut self, values: Vec<f64>) -> Self {
46        self.color_by = Some(values);
47        self
48    }
49
50    /// Set line alpha.
51    #[must_use]
52    pub fn with_alpha(mut self, alpha: f32) -> Self {
53        self.alpha = alpha.clamp(0.0, 1.0);
54        self
55    }
56
57    /// Toggle column labels.
58    #[must_use]
59    pub fn with_labels(mut self, show: bool) -> Self {
60        self.show_labels = show;
61        self
62    }
63
64    /// Get min/max for a column.
65    fn column_range(&self, col_idx: usize) -> (f64, f64) {
66        let mut min = f64::INFINITY;
67        let mut max = f64::NEG_INFINITY;
68
69        for row in &self.data {
70            if let Some(&v) = row.get(col_idx) {
71                if v.is_finite() {
72                    min = min.min(v);
73                    max = max.max(v);
74                }
75            }
76        }
77
78        if min == f64::INFINITY {
79            (0.0, 1.0)
80        } else if (max - min).abs() < 1e-10 {
81            (min - 0.5, max + 0.5)
82        } else {
83            (min, max)
84        }
85    }
86
87    /// Get color for a row.
88    fn get_row_color(&self, row_idx: usize) -> Color {
89        if let Some(ref values) = self.color_by {
90            if let Some(&v) = values.get(row_idx) {
91                let mut min = f64::INFINITY;
92                let mut max = f64::NEG_INFINITY;
93                for &val in values {
94                    if val.is_finite() {
95                        min = min.min(val);
96                        max = max.max(val);
97                    }
98                }
99                let range = (max - min).max(1e-10);
100                let t = ((v - min) / range).clamp(0.0, 1.0);
101                // Blue to red gradient
102                Color::new(t as f32, 0.3, (1.0 - t) as f32, self.alpha)
103            } else {
104                Color::new(0.3, 0.5, 0.8, self.alpha)
105            }
106        } else {
107            Color::new(0.3, 0.5, 0.8, self.alpha)
108        }
109    }
110}
111
112impl Default for ParallelCoordinates {
113    fn default() -> Self {
114        Self::new(Vec::new(), Vec::new())
115    }
116}
117
118impl Widget for ParallelCoordinates {
119    fn type_id(&self) -> TypeId {
120        TypeId::of::<Self>()
121    }
122
123    fn measure(&self, constraints: Constraints) -> Size {
124        Size::new(
125            constraints.max_width.min(80.0),
126            constraints.max_height.min(20.0),
127        )
128    }
129
130    fn layout(&mut self, bounds: Rect) -> LayoutResult {
131        self.bounds = bounds;
132        LayoutResult {
133            size: Size::new(bounds.width, bounds.height),
134        }
135    }
136
137    fn paint(&self, canvas: &mut dyn Canvas) {
138        if self.bounds.width < 20.0 || self.bounds.height < 5.0 || self.columns.is_empty() {
139            return;
140        }
141
142        let margin_top = if self.show_labels { 2.0 } else { 0.0 };
143        let margin_bottom = 1.0;
144        let margin_left = 2.0;
145        let margin_right = 2.0;
146
147        let plot_x = self.bounds.x + margin_left;
148        let plot_y = self.bounds.y + margin_top;
149        let plot_width = self.bounds.width - margin_left - margin_right;
150        let plot_height = self.bounds.height - margin_top - margin_bottom;
151
152        if plot_width <= 0.0 || plot_height <= 0.0 {
153            return;
154        }
155
156        let n_cols = self.columns.len();
157        let col_spacing = plot_width / (n_cols - 1).max(1) as f32;
158
159        let label_style = TextStyle {
160            color: Color::new(0.7, 0.7, 0.7, 1.0),
161            ..Default::default()
162        };
163
164        let axis_style = TextStyle {
165            color: Color::new(0.4, 0.4, 0.4, 1.0),
166            ..Default::default()
167        };
168
169        // Draw axes and labels
170        for (i, col_name) in self.columns.iter().enumerate() {
171            let x = plot_x + i as f32 * col_spacing;
172
173            // Draw axis line
174            for y_step in 0..(plot_height as usize) {
175                canvas.draw_text("│", Point::new(x, plot_y + y_step as f32), &axis_style);
176            }
177
178            // Draw column label
179            if self.show_labels {
180                let label: String = col_name.chars().take(8).collect();
181                canvas.draw_text(&label, Point::new(x, self.bounds.y), &label_style);
182            }
183        }
184
185        // Precompute column ranges
186        let ranges: Vec<(f64, f64)> = (0..n_cols).map(|i| self.column_range(i)).collect();
187
188        // Draw data lines
189        for (row_idx, row) in self.data.iter().enumerate() {
190            if row.len() != n_cols {
191                continue;
192            }
193
194            let color = self.get_row_color(row_idx);
195            let style = TextStyle {
196                color,
197                ..Default::default()
198            };
199
200            // Draw line segments between consecutive axes
201            for col_idx in 0..(n_cols - 1) {
202                let x1 = plot_x + col_idx as f32 * col_spacing;
203                let x2 = plot_x + (col_idx + 1) as f32 * col_spacing;
204
205                let (min1, max1) = ranges[col_idx];
206                let (min2, max2) = ranges[col_idx + 1];
207
208                let v1 = row[col_idx];
209                let v2 = row[col_idx + 1];
210
211                if !v1.is_finite() || !v2.is_finite() {
212                    continue;
213                }
214
215                let y1_norm = if max1 > min1 {
216                    (v1 - min1) / (max1 - min1)
217                } else {
218                    0.5
219                };
220                let y2_norm = if max2 > min2 {
221                    (v2 - min2) / (max2 - min2)
222                } else {
223                    0.5
224                };
225
226                let y1 = plot_y + ((1.0 - y1_norm) * plot_height as f64) as f32;
227                let y2 = plot_y + ((1.0 - y2_norm) * plot_height as f64) as f32;
228
229                // Draw line using Braille or simple chars
230                // For terminal, we approximate with diagonal chars
231                let dx = x2 - x1;
232                let dy = y2 - y1;
233                let steps = (dx.abs().max(dy.abs()) as usize).max(1);
234
235                for step in 0..=steps {
236                    let t = step as f32 / steps as f32;
237                    let px = x1 + t * dx;
238                    let py = y1 + t * dy;
239
240                    if px >= plot_x
241                        && px < plot_x + plot_width
242                        && py >= plot_y
243                        && py < plot_y + plot_height
244                    {
245                        let ch = if dy.abs() < 0.3 {
246                            '─'
247                        } else if dy > 0.0 {
248                            '╲'
249                        } else {
250                            '╱'
251                        };
252                        canvas.draw_text(&ch.to_string(), Point::new(px, py), &style);
253                    }
254                }
255            }
256        }
257    }
258
259    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
260        None
261    }
262
263    fn children(&self) -> &[Box<dyn Widget>] {
264        &[]
265    }
266
267    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
268        &mut []
269    }
270}
271
272impl Brick for ParallelCoordinates {
273    fn brick_name(&self) -> &'static str {
274        "ParallelCoordinates"
275    }
276
277    fn assertions(&self) -> &[BrickAssertion] {
278        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
279        ASSERTIONS
280    }
281
282    fn budget(&self) -> BrickBudget {
283        BrickBudget::uniform(16)
284    }
285
286    fn verify(&self) -> BrickVerification {
287        let mut passed = Vec::new();
288        let mut failed = Vec::new();
289
290        if self.bounds.width >= 20.0 && self.bounds.height >= 5.0 {
291            passed.push(BrickAssertion::max_latency_ms(16));
292        } else {
293            failed.push((
294                BrickAssertion::max_latency_ms(16),
295                "Size too small".to_string(),
296            ));
297        }
298
299        BrickVerification {
300            passed,
301            failed,
302            verification_time: Duration::from_micros(5),
303        }
304    }
305
306    fn to_html(&self) -> String {
307        String::new()
308    }
309
310    fn to_css(&self) -> String {
311        String::new()
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::direct::{CellBuffer, DirectTerminalCanvas};
319
320    #[test]
321    fn test_parallel_coords_new() {
322        let columns = vec!["A".to_string(), "B".to_string(), "C".to_string()];
323        let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
324        let plot = ParallelCoordinates::new(columns, data);
325        assert_eq!(plot.columns.len(), 3);
326        assert_eq!(plot.data.len(), 2);
327    }
328
329    #[test]
330    fn test_parallel_coords_empty() {
331        let plot = ParallelCoordinates::default();
332        assert!(plot.columns.is_empty());
333        assert!(plot.data.is_empty());
334    }
335
336    #[test]
337    fn test_parallel_coords_with_color() {
338        let columns = vec!["A".to_string(), "B".to_string()];
339        let data = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
340        let plot = ParallelCoordinates::new(columns, data)
341            .with_color_by(vec![0.0, 1.0])
342            .with_alpha(0.7);
343        assert!(plot.color_by.is_some());
344        assert!((plot.alpha - 0.7).abs() < 0.01);
345    }
346
347    #[test]
348    fn test_parallel_coords_with_alpha_clamped() {
349        let columns = vec!["A".to_string()];
350        let data = vec![vec![1.0]];
351        let plot = ParallelCoordinates::new(columns, data).with_alpha(2.0); // Should clamp to 1.0
352        assert!((plot.alpha - 1.0).abs() < 0.01);
353
354        let columns2 = vec!["B".to_string()];
355        let data2 = vec![vec![1.0]];
356        let plot2 = ParallelCoordinates::new(columns2, data2).with_alpha(-0.5); // Should clamp to 0.0
357        assert!((plot2.alpha - 0.0).abs() < 0.01);
358    }
359
360    #[test]
361    fn test_parallel_coords_with_labels() {
362        let columns = vec!["A".to_string()];
363        let data = vec![vec![1.0]];
364        let plot = ParallelCoordinates::new(columns, data).with_labels(false);
365        assert!(!plot.show_labels);
366    }
367
368    #[test]
369    fn test_parallel_coords_paint() {
370        let columns = vec!["A".to_string(), "B".to_string(), "C".to_string()];
371        let data = vec![
372            vec![1.0, 5.0, 3.0],
373            vec![2.0, 4.0, 6.0],
374            vec![3.0, 3.0, 1.0],
375        ];
376        let mut plot = ParallelCoordinates::new(columns, data);
377
378        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
379        plot.layout(bounds);
380
381        let mut buffer = CellBuffer::new(80, 20);
382        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
383        plot.paint(&mut canvas);
384    }
385
386    #[test]
387    fn test_parallel_coords_paint_empty_columns() {
388        let mut plot = ParallelCoordinates::default();
389        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
390        plot.layout(bounds);
391
392        let mut buffer = CellBuffer::new(80, 20);
393        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
394        plot.paint(&mut canvas); // Should return early
395    }
396
397    #[test]
398    fn test_parallel_coords_paint_small_bounds() {
399        let columns = vec!["A".to_string(), "B".to_string()];
400        let data = vec![vec![1.0, 2.0]];
401        let mut plot = ParallelCoordinates::new(columns, data);
402        plot.bounds = Rect::new(0.0, 0.0, 10.0, 3.0); // Too small
403
404        let mut buffer = CellBuffer::new(20, 10);
405        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
406        plot.paint(&mut canvas); // Should return early
407    }
408
409    #[test]
410    fn test_parallel_coords_paint_no_labels() {
411        let columns = vec!["A".to_string(), "B".to_string(), "C".to_string()];
412        let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
413        let mut plot = ParallelCoordinates::new(columns, data).with_labels(false);
414
415        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
416        plot.layout(bounds);
417
418        let mut buffer = CellBuffer::new(80, 20);
419        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
420        plot.paint(&mut canvas);
421    }
422
423    #[test]
424    fn test_parallel_coords_paint_with_color_by() {
425        let columns = vec!["A".to_string(), "B".to_string()];
426        let data = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
427        let mut plot = ParallelCoordinates::new(columns, data).with_color_by(vec![0.0, 0.5, 1.0]);
428
429        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
430        plot.layout(bounds);
431
432        let mut buffer = CellBuffer::new(80, 20);
433        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
434        plot.paint(&mut canvas);
435    }
436
437    #[test]
438    fn test_parallel_coords_paint_mismatched_row_length() {
439        let columns = vec!["A".to_string(), "B".to_string(), "C".to_string()];
440        let data = vec![
441            vec![1.0, 2.0, 3.0],
442            vec![4.0, 5.0], // Wrong length - should be skipped
443            vec![7.0, 8.0, 9.0],
444        ];
445        let mut plot = ParallelCoordinates::new(columns, data);
446
447        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
448        plot.layout(bounds);
449
450        let mut buffer = CellBuffer::new(80, 20);
451        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
452        plot.paint(&mut canvas);
453    }
454
455    #[test]
456    fn test_parallel_coords_paint_with_nan() {
457        let columns = vec!["A".to_string(), "B".to_string()];
458        let data = vec![
459            vec![1.0, 2.0],
460            vec![f64::NAN, 3.0],                    // NaN value
461            vec![f64::INFINITY, f64::NEG_INFINITY], // Infinite values
462        ];
463        let mut plot = ParallelCoordinates::new(columns, data);
464
465        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
466        plot.layout(bounds);
467
468        let mut buffer = CellBuffer::new(80, 20);
469        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
470        plot.paint(&mut canvas);
471    }
472
473    #[test]
474    fn test_parallel_coords_column_range() {
475        let columns = vec!["A".to_string()];
476        let data = vec![vec![1.0], vec![5.0], vec![3.0]];
477        let plot = ParallelCoordinates::new(columns, data);
478        let (min, max) = plot.column_range(0);
479        assert!((min - 1.0).abs() < 0.01);
480        assert!((max - 5.0).abs() < 0.01);
481    }
482
483    #[test]
484    fn test_parallel_coords_column_range_empty() {
485        let columns = vec!["A".to_string()];
486        let data: Vec<Vec<f64>> = vec![];
487        let plot = ParallelCoordinates::new(columns, data);
488        let (min, max) = plot.column_range(0);
489        assert!((min - 0.0).abs() < 0.01);
490        assert!((max - 1.0).abs() < 0.01);
491    }
492
493    #[test]
494    fn test_parallel_coords_column_range_constant() {
495        let columns = vec!["A".to_string()];
496        let data = vec![vec![5.0], vec![5.0], vec![5.0]];
497        let plot = ParallelCoordinates::new(columns, data);
498        let (min, max) = plot.column_range(0);
499        // Range should be expanded when all values are the same
500        assert!(max > min);
501    }
502
503    #[test]
504    fn test_parallel_coords_column_range_with_nan() {
505        let columns = vec!["A".to_string()];
506        let data = vec![vec![1.0], vec![f64::NAN], vec![5.0]];
507        let plot = ParallelCoordinates::new(columns, data);
508        let (min, max) = plot.column_range(0);
509        // Should ignore NaN values
510        assert!((min - 1.0).abs() < 0.01);
511        assert!((max - 5.0).abs() < 0.01);
512    }
513
514    #[test]
515    fn test_parallel_coords_get_row_color_no_color_by() {
516        let columns = vec!["A".to_string()];
517        let data = vec![vec![1.0], vec![2.0]];
518        let plot = ParallelCoordinates::new(columns, data);
519        let color = plot.get_row_color(0);
520        // Default color is blue-ish
521        assert!(color.b > color.r);
522    }
523
524    #[test]
525    fn test_parallel_coords_get_row_color_with_color_by() {
526        let columns = vec!["A".to_string()];
527        let data = vec![vec![1.0], vec![2.0], vec![3.0]];
528        let plot = ParallelCoordinates::new(columns, data).with_color_by(vec![0.0, 0.5, 1.0]);
529
530        let color0 = plot.get_row_color(0); // Low value - more blue
531        let color2 = plot.get_row_color(2); // High value - more red
532
533        assert!(color0.b > color0.r);
534        assert!(color2.r > color2.b);
535    }
536
537    #[test]
538    fn test_parallel_coords_get_row_color_out_of_range() {
539        let columns = vec!["A".to_string()];
540        let data = vec![vec![1.0]];
541        let plot = ParallelCoordinates::new(columns, data).with_color_by(vec![0.0]);
542
543        let color = plot.get_row_color(100); // Out of range
544                                             // Should return default color
545        assert!(color.b > 0.0);
546    }
547
548    #[test]
549    fn test_parallel_coords_verify() {
550        let mut plot =
551            ParallelCoordinates::new(vec!["A".to_string(), "B".to_string()], vec![vec![1.0, 2.0]]);
552        plot.bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
553        assert!(plot.verify().is_valid());
554    }
555
556    #[test]
557    fn test_parallel_coords_verify_small_bounds() {
558        let mut plot = ParallelCoordinates::new(vec!["A".to_string()], vec![vec![1.0]]);
559        plot.bounds = Rect::new(0.0, 0.0, 10.0, 3.0);
560        let verification = plot.verify();
561        assert!(!verification.failed.is_empty());
562    }
563
564    #[test]
565    fn test_parallel_coords_brick_name() {
566        let plot = ParallelCoordinates::default();
567        assert_eq!(plot.brick_name(), "ParallelCoordinates");
568    }
569
570    #[test]
571    fn test_parallel_coords_assertions() {
572        let plot = ParallelCoordinates::default();
573        assert!(!plot.assertions().is_empty());
574    }
575
576    #[test]
577    fn test_parallel_coords_budget() {
578        let plot = ParallelCoordinates::default();
579        let budget = plot.budget();
580        assert!(budget.measure_ms > 0);
581    }
582
583    #[test]
584    fn test_parallel_coords_to_html() {
585        let plot = ParallelCoordinates::default();
586        assert!(plot.to_html().is_empty());
587    }
588
589    #[test]
590    fn test_parallel_coords_to_css() {
591        let plot = ParallelCoordinates::default();
592        assert!(plot.to_css().is_empty());
593    }
594
595    #[test]
596    fn test_parallel_coords_measure() {
597        let columns = vec!["A".to_string(), "B".to_string()];
598        let data = vec![vec![1.0, 2.0]];
599        let plot = ParallelCoordinates::new(columns, data);
600        let size = plot.measure(Constraints {
601            min_width: 0.0,
602            max_width: 100.0,
603            min_height: 0.0,
604            max_height: 50.0,
605        });
606        assert!(size.width > 0.0);
607        assert!(size.height > 0.0);
608    }
609
610    #[test]
611    fn test_parallel_coords_layout() {
612        let columns = vec!["A".to_string()];
613        let data = vec![vec![1.0]];
614        let mut plot = ParallelCoordinates::new(columns, data);
615        let result = plot.layout(Rect::new(10.0, 20.0, 80.0, 40.0));
616        assert!((plot.bounds.x - 10.0).abs() < f32::EPSILON);
617        assert!((plot.bounds.y - 20.0).abs() < f32::EPSILON);
618        assert!(result.size.width > 0.0);
619    }
620
621    #[test]
622    fn test_parallel_coords_type_id() {
623        let plot = ParallelCoordinates::default();
624        let type_id = Widget::type_id(&plot);
625        assert_eq!(type_id, TypeId::of::<ParallelCoordinates>());
626    }
627
628    #[test]
629    fn test_parallel_coords_children() {
630        let plot = ParallelCoordinates::default();
631        assert!(plot.children().is_empty());
632    }
633
634    #[test]
635    fn test_parallel_coords_children_mut() {
636        let mut plot = ParallelCoordinates::default();
637        assert!(plot.children_mut().is_empty());
638    }
639
640    #[test]
641    fn test_parallel_coords_event() {
642        let mut plot = ParallelCoordinates::default();
643        let result = plot.event(&Event::FocusIn);
644        assert!(result.is_none());
645    }
646
647    #[test]
648    fn test_parallel_coords_clone() {
649        let columns = vec!["A".to_string(), "B".to_string()];
650        let data = vec![vec![1.0, 2.0]];
651        let plot = ParallelCoordinates::new(columns, data).with_alpha(0.8);
652        let cloned = plot;
653        assert_eq!(cloned.columns.len(), 2);
654        assert!((cloned.alpha - 0.8).abs() < 0.01);
655    }
656
657    #[test]
658    fn test_parallel_coords_debug() {
659        let plot = ParallelCoordinates::default();
660        let debug = format!("{:?}", plot);
661        assert!(debug.contains("ParallelCoordinates"));
662    }
663
664    #[test]
665    fn test_parallel_coords_single_column() {
666        // Edge case: single column (no lines to draw between columns)
667        let columns = vec!["A".to_string()];
668        let data = vec![vec![1.0], vec![2.0], vec![3.0]];
669        let mut plot = ParallelCoordinates::new(columns, data);
670
671        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
672        plot.layout(bounds);
673
674        let mut buffer = CellBuffer::new(80, 20);
675        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
676        plot.paint(&mut canvas);
677    }
678
679    #[test]
680    fn test_parallel_coords_long_column_names() {
681        let columns = vec![
682            "VeryLongColumnNameThatShouldBeTruncated".to_string(),
683            "AnotherLongName".to_string(),
684        ];
685        let data = vec![vec![1.0, 2.0]];
686        let mut plot = ParallelCoordinates::new(columns, data);
687
688        let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
689        plot.layout(bounds);
690
691        let mut buffer = CellBuffer::new(80, 20);
692        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
693        plot.paint(&mut canvas);
694    }
695}