1use 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#[derive(Debug, Clone)]
14pub struct ParallelCoordinates {
15 columns: Vec<String>,
17 data: Vec<Vec<f64>>,
19 color_by: Option<Vec<f64>>,
21 alpha: f32,
23 show_labels: bool,
25 bounds: Rect,
27}
28
29impl ParallelCoordinates {
30 #[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 #[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 #[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 #[must_use]
59 pub fn with_labels(mut self, show: bool) -> Self {
60 self.show_labels = show;
61 self
62 }
63
64 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 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 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 for (i, col_name) in self.columns.iter().enumerate() {
171 let x = plot_x + i as f32 * col_spacing;
172
173 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 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 let ranges: Vec<(f64, f64)> = (0..n_cols).map(|i| self.column_range(i)).collect();
187
188 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 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 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); 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); 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); }
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); let mut buffer = CellBuffer::new(20, 10);
405 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
406 plot.paint(&mut canvas); }
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], 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], vec![f64::INFINITY, f64::NEG_INFINITY], ];
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 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 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 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); let color2 = plot.get_row_color(2); 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); 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 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}