Skip to main content

presentar_terminal/widgets/
radar_plot.rs

1//! Radar (Spider) plot widget.
2//!
3//! Implements SPEC-024 Section 26.6.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::f64::consts::PI;
11use std::time::Duration;
12
13/// A series in the radar plot.
14#[derive(Debug, Clone)]
15pub struct RadarSeries {
16    /// Series name.
17    pub name: String,
18    /// Values (one per axis).
19    pub values: Vec<f64>,
20    /// Series color.
21    pub color: Color,
22}
23
24impl RadarSeries {
25    /// Create a new radar series.
26    #[must_use]
27    pub fn new(name: impl Into<String>, values: Vec<f64>, color: Color) -> Self {
28        Self {
29            name: name.into(),
30            values,
31            color,
32        }
33    }
34}
35
36/// Radar (Spider) plot widget.
37#[derive(Debug, Clone)]
38pub struct RadarPlot {
39    /// Axis labels.
40    axes: Vec<String>,
41    /// Data series.
42    series: Vec<RadarSeries>,
43    /// Fill polygons.
44    fill: bool,
45    /// Fill alpha.
46    fill_alpha: f32,
47    /// Show axis labels.
48    show_labels: bool,
49    /// Show grid.
50    show_grid: bool,
51    /// Cached bounds.
52    bounds: Rect,
53}
54
55impl RadarPlot {
56    /// Create a new radar plot.
57    #[must_use]
58    pub fn new(axes: Vec<String>) -> Self {
59        Self {
60            axes,
61            series: Vec::new(),
62            fill: true,
63            fill_alpha: 0.3,
64            show_labels: true,
65            show_grid: true,
66            bounds: Rect::default(),
67        }
68    }
69
70    /// Add a series.
71    #[must_use]
72    pub fn with_series(mut self, series: RadarSeries) -> Self {
73        self.series.push(series);
74        self
75    }
76
77    /// Toggle fill.
78    #[must_use]
79    pub fn with_fill(mut self, fill: bool) -> Self {
80        self.fill = fill;
81        self
82    }
83
84    /// Set fill alpha.
85    #[must_use]
86    pub fn with_fill_alpha(mut self, alpha: f32) -> Self {
87        self.fill_alpha = alpha.clamp(0.0, 1.0);
88        self
89    }
90
91    /// Toggle labels.
92    #[must_use]
93    pub fn with_labels(mut self, show: bool) -> Self {
94        self.show_labels = show;
95        self
96    }
97
98    /// Toggle grid.
99    #[must_use]
100    pub fn with_grid(mut self, show: bool) -> Self {
101        self.show_grid = show;
102        self
103    }
104
105    /// Get maximum value across all series.
106    fn max_value(&self) -> f64 {
107        let mut max = 0.0f64;
108        for s in &self.series {
109            for &v in &s.values {
110                if v.is_finite() && v > max {
111                    max = v;
112                }
113            }
114        }
115        max.max(1.0)
116    }
117
118    /// Check if point is within bounds.
119    fn in_bounds(&self, x: f32, y: f32) -> bool {
120        x >= self.bounds.x
121            && x < self.bounds.x + self.bounds.width
122            && y >= self.bounds.y
123            && y < self.bounds.y + self.bounds.height
124    }
125
126    /// Draw a point if in bounds.
127    fn draw_point(&self, canvas: &mut dyn Canvas, x: f32, y: f32, ch: &str, style: &TextStyle) {
128        if self.in_bounds(x, y) {
129            canvas.draw_text(ch, Point::new(x, y), style);
130        }
131    }
132}
133
134impl Default for RadarPlot {
135    fn default() -> Self {
136        Self::new(Vec::new())
137    }
138}
139
140impl Widget for RadarPlot {
141    fn type_id(&self) -> TypeId {
142        TypeId::of::<Self>()
143    }
144
145    fn measure(&self, constraints: Constraints) -> Size {
146        let size = constraints.max_width.min(constraints.max_height).min(40.0);
147        Size::new(size, size)
148    }
149
150    fn layout(&mut self, bounds: Rect) -> LayoutResult {
151        self.bounds = bounds;
152        LayoutResult {
153            size: Size::new(bounds.width, bounds.height),
154        }
155    }
156
157    #[allow(clippy::too_many_lines)]
158    fn paint(&self, canvas: &mut dyn Canvas) {
159        if self.bounds.width < 1.0 || self.bounds.height < 1.0 {
160            return;
161        }
162
163        let n_axes = self.axes.len();
164        let center_x = self.bounds.x + self.bounds.width / 2.0;
165        let center_y = self.bounds.y + self.bounds.height / 2.0;
166        let radius = (self.bounds.width.min(self.bounds.height) / 2.0 - 3.0).max(2.0);
167        let max_val = self.max_value();
168
169        let grid_style = TextStyle {
170            color: Color::new(0.3, 0.3, 0.3, 1.0),
171            ..Default::default()
172        };
173
174        let label_style = TextStyle {
175            color: Color::new(0.7, 0.7, 0.7, 1.0),
176            ..Default::default()
177        };
178
179        // Draw grid circles
180        if self.show_grid {
181            for level in [0.25, 0.5, 0.75, 1.0] {
182                let r = radius * level;
183                // Draw circle approximation with dots
184                for i in 0..(n_axes * 4) {
185                    let angle = 2.0 * PI * (i as f64) / (n_axes * 4) as f64 - PI / 2.0;
186                    let x = center_x + (r * angle.cos() as f32);
187                    let y = center_y + (r * angle.sin() as f32);
188                    self.draw_point(canvas, x, y, "·", &grid_style);
189                }
190            }
191        }
192
193        // Draw axes
194        for i in 0..n_axes {
195            let angle = 2.0 * PI * (i as f64) / (n_axes as f64) - PI / 2.0;
196            let end_x = center_x + (radius * angle.cos() as f32);
197            let end_y = center_y + (radius * angle.sin() as f32);
198
199            // Draw axis line
200            let steps = (radius as usize).max(1);
201            for step in 0..=steps {
202                let t = step as f32 / steps as f32;
203                let x = center_x + t * (end_x - center_x);
204                let y = center_y + t * (end_y - center_y);
205                self.draw_point(canvas, x, y, "·", &grid_style);
206            }
207
208            // Draw label
209            if self.show_labels {
210                let label_r = radius + 2.0;
211                let label_x = center_x + (label_r * angle.cos() as f32);
212                let label_y = center_y + (label_r * angle.sin() as f32);
213
214                let label: String = self.axes[i].chars().take(6).collect();
215                self.draw_point(canvas, label_x, label_y, &label, &label_style);
216            }
217        }
218
219        // Draw series
220        for series in &self.series {
221            if series.values.len() != n_axes {
222                continue;
223            }
224
225            let style = TextStyle {
226                color: series.color,
227                ..Default::default()
228            };
229
230            // Calculate points
231            let points: Vec<(f32, f32)> = (0..n_axes)
232                .map(|i| {
233                    let angle = 2.0 * PI * (i as f64) / (n_axes as f64) - PI / 2.0;
234                    let v = series.values[i].max(0.0) / max_val;
235                    let r = radius * v as f32;
236                    (
237                        center_x + (r * angle.cos() as f32),
238                        center_y + (r * angle.sin() as f32),
239                    )
240                })
241                .collect();
242
243            // Draw polygon edges
244            for i in 0..n_axes {
245                let (x1, y1) = points[i];
246                let (x2, y2) = points[(i + 1) % n_axes];
247
248                let dx = x2 - x1;
249                let dy = y2 - y1;
250                let steps = ((dx.abs() + dy.abs()) as usize).max(1);
251
252                for step in 0..=steps {
253                    let t = step as f32 / steps as f32;
254                    let x = x1 + t * dx;
255                    let y = y1 + t * dy;
256                    self.draw_point(canvas, x, y, "●", &style);
257                }
258            }
259
260            // Mark vertices
261            for &(x, y) in &points {
262                self.draw_point(canvas, x, y, "◆", &style);
263            }
264        }
265
266        // Draw legend
267        if !self.series.is_empty() {
268            let legend_y = self.bounds.y + self.bounds.height - 1.0;
269            let mut legend_x = self.bounds.x;
270
271            for series in &self.series {
272                let style = TextStyle {
273                    color: series.color,
274                    ..Default::default()
275                };
276                let label = format!("● {} ", series.name);
277                canvas.draw_text(&label, Point::new(legend_x, legend_y), &style);
278                legend_x += label.len() as f32;
279            }
280        }
281    }
282
283    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
284        None
285    }
286
287    fn children(&self) -> &[Box<dyn Widget>] {
288        &[]
289    }
290
291    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
292        &mut []
293    }
294}
295
296impl Brick for RadarPlot {
297    fn brick_name(&self) -> &'static str {
298        "RadarPlot"
299    }
300
301    fn assertions(&self) -> &[BrickAssertion] {
302        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
303        ASSERTIONS
304    }
305
306    fn budget(&self) -> BrickBudget {
307        BrickBudget::uniform(16)
308    }
309
310    fn verify(&self) -> BrickVerification {
311        let mut passed = Vec::new();
312        let mut failed = Vec::new();
313
314        if self.bounds.width >= 10.0 && self.bounds.height >= 5.0 {
315            passed.push(BrickAssertion::max_latency_ms(16));
316        } else {
317            failed.push((
318                BrickAssertion::max_latency_ms(16),
319                "Size too small".to_string(),
320            ));
321        }
322
323        // Check series consistency
324        for series in &self.series {
325            if series.values.len() != self.axes.len() {
326                failed.push((
327                    BrickAssertion::max_latency_ms(16),
328                    format!("Series {} has wrong number of values", series.name),
329                ));
330            }
331        }
332
333        BrickVerification {
334            passed,
335            failed,
336            verification_time: Duration::from_micros(5),
337        }
338    }
339
340    fn to_html(&self) -> String {
341        String::new()
342    }
343
344    fn to_css(&self) -> String {
345        String::new()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::direct::{CellBuffer, DirectTerminalCanvas};
353
354    #[test]
355    fn test_radar_plot_new() {
356        let axes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
357        let plot = RadarPlot::new(axes);
358        assert_eq!(plot.axes.len(), 3);
359    }
360
361    #[test]
362    fn test_radar_plot_empty() {
363        let plot = RadarPlot::default();
364        assert!(plot.axes.is_empty());
365    }
366
367    #[test]
368    fn test_radar_plot_with_series() {
369        let axes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
370        let series = RadarSeries::new("Test", vec![1.0, 2.0, 3.0], Color::BLUE);
371        let plot = RadarPlot::new(axes).with_series(series);
372        assert_eq!(plot.series.len(), 1);
373    }
374
375    #[test]
376    fn test_radar_plot_max_value() {
377        let axes = vec!["A".to_string(), "B".to_string()];
378        let series = RadarSeries::new("Test", vec![5.0, 10.0], Color::BLUE);
379        let plot = RadarPlot::new(axes).with_series(series);
380        assert!((plot.max_value() - 10.0).abs() < 0.01);
381    }
382
383    #[test]
384    fn test_radar_plot_max_value_empty() {
385        let plot = RadarPlot::default();
386        assert!((plot.max_value() - 1.0).abs() < 0.01);
387    }
388
389    #[test]
390    fn test_radar_plot_paint() {
391        let axes = vec![
392            "Speed".to_string(),
393            "Power".to_string(),
394            "Range".to_string(),
395            "Defense".to_string(),
396            "Attack".to_string(),
397        ];
398        let series1 = RadarSeries::new("Player 1", vec![8.0, 6.0, 7.0, 5.0, 9.0], Color::BLUE);
399        let series2 = RadarSeries::new("Player 2", vec![6.0, 8.0, 5.0, 7.0, 6.0], Color::RED);
400
401        let mut plot = RadarPlot::new(axes)
402            .with_series(series1)
403            .with_series(series2)
404            .with_fill(true);
405
406        let bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
407        plot.layout(bounds);
408
409        let mut buffer = CellBuffer::new(40, 20);
410        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
411        plot.paint(&mut canvas);
412    }
413
414    #[test]
415    fn test_radar_plot_verify() {
416        let axes = vec!["A".to_string(), "B".to_string()];
417        let series = RadarSeries::new("Test", vec![1.0, 2.0], Color::BLUE);
418        let mut plot = RadarPlot::new(axes).with_series(series);
419        plot.bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
420        assert!(plot.verify().is_valid());
421    }
422
423    #[test]
424    fn test_radar_plot_verify_mismatch() {
425        let axes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
426        let series = RadarSeries::new("Test", vec![1.0, 2.0], Color::BLUE); // Wrong length
427        let mut plot = RadarPlot::new(axes).with_series(series);
428        plot.bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
429        assert!(!plot.verify().is_valid());
430    }
431
432    #[test]
433    fn test_radar_plot_brick_name() {
434        let plot = RadarPlot::default();
435        assert_eq!(plot.brick_name(), "RadarPlot");
436    }
437
438    #[test]
439    fn test_radar_series_new() {
440        let series = RadarSeries::new("Test", vec![1.0, 2.0, 3.0], Color::GREEN);
441        assert_eq!(series.name, "Test");
442        assert_eq!(series.values.len(), 3);
443    }
444
445    // =========================================================================
446    // Additional coverage tests
447    // =========================================================================
448
449    #[test]
450    fn test_with_fill_alpha() {
451        let plot = RadarPlot::default().with_fill_alpha(0.5);
452        assert!((plot.fill_alpha - 0.5).abs() < 0.01);
453    }
454
455    #[test]
456    fn test_with_fill_alpha_clamped() {
457        let plot = RadarPlot::default().with_fill_alpha(2.0);
458        assert!((plot.fill_alpha - 1.0).abs() < 0.01);
459
460        let plot2 = RadarPlot::default().with_fill_alpha(-0.5);
461        assert!((plot2.fill_alpha - 0.0).abs() < 0.01);
462    }
463
464    #[test]
465    fn test_with_labels() {
466        let plot = RadarPlot::default().with_labels(false);
467        assert!(!plot.show_labels);
468    }
469
470    #[test]
471    fn test_with_grid() {
472        let plot = RadarPlot::default().with_grid(false);
473        assert!(!plot.show_grid);
474    }
475
476    #[test]
477    fn test_with_fill() {
478        let plot = RadarPlot::default().with_fill(false);
479        assert!(!plot.fill);
480    }
481
482    #[test]
483    fn test_max_value_with_nan() {
484        let axes = vec!["A".to_string(), "B".to_string()];
485        let series = RadarSeries::new("Test", vec![f64::NAN, 5.0], Color::BLUE);
486        let plot = RadarPlot::new(axes).with_series(series);
487        // NaN should be skipped
488        assert!((plot.max_value() - 5.0).abs() < 0.01);
489    }
490
491    #[test]
492    fn test_max_value_with_infinity() {
493        let axes = vec!["A".to_string(), "B".to_string()];
494        let series = RadarSeries::new("Test", vec![f64::INFINITY, 5.0], Color::BLUE);
495        let plot = RadarPlot::new(axes).with_series(series);
496        // Infinity should be skipped (not is_finite)
497        assert!((plot.max_value() - 5.0).abs() < 0.01);
498    }
499
500    #[test]
501    fn test_max_value_negative() {
502        let axes = vec!["A".to_string()];
503        let series = RadarSeries::new("Test", vec![-5.0], Color::BLUE);
504        let plot = RadarPlot::new(axes).with_series(series);
505        // max_value returns at least 1.0
506        assert!((plot.max_value() - 1.0).abs() < 0.01);
507    }
508
509    #[test]
510    fn test_paint_too_small() {
511        let mut plot = RadarPlot::new(vec!["A".to_string()]);
512        plot.bounds = Rect::new(0.0, 0.0, 0.5, 0.5); // Too small
513
514        let mut buffer = CellBuffer::new(1, 1);
515        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
516        plot.paint(&mut canvas); // Should return early
517    }
518
519    #[test]
520    fn test_paint_no_grid_no_labels() {
521        let axes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
522        let series = RadarSeries::new("Test", vec![5.0, 6.0, 7.0], Color::BLUE);
523        let mut plot = RadarPlot::new(axes)
524            .with_series(series)
525            .with_grid(false)
526            .with_labels(false);
527        plot.bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
528
529        let mut buffer = CellBuffer::new(40, 20);
530        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
531        plot.paint(&mut canvas);
532    }
533
534    #[test]
535    fn test_paint_mismatched_series() {
536        let axes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
537        // Series has 2 values but there are 3 axes - should be skipped
538        let series = RadarSeries::new("Test", vec![1.0, 2.0], Color::BLUE);
539        let mut plot = RadarPlot::new(axes).with_series(series);
540        plot.bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
541
542        let mut buffer = CellBuffer::new(40, 20);
543        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
544        plot.paint(&mut canvas); // Series should be skipped
545    }
546
547    #[test]
548    fn test_paint_empty_axes() {
549        let mut plot = RadarPlot::new(vec![]); // No axes
550        plot.bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
551
552        let mut buffer = CellBuffer::new(40, 20);
553        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
554        plot.paint(&mut canvas);
555    }
556
557    #[test]
558    fn test_paint_negative_values() {
559        let axes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
560        let series = RadarSeries::new("Test", vec![-1.0, 5.0, 3.0], Color::BLUE);
561        let mut plot = RadarPlot::new(axes).with_series(series);
562        plot.bounds = Rect::new(0.0, 0.0, 40.0, 20.0);
563
564        let mut buffer = CellBuffer::new(40, 20);
565        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
566        plot.paint(&mut canvas); // Negative values clamped to 0
567    }
568
569    #[test]
570    fn test_verify_too_small() {
571        let mut plot = RadarPlot::new(vec!["A".to_string()]);
572        plot.bounds = Rect::new(0.0, 0.0, 5.0, 3.0); // Too small
573        let result = plot.verify();
574        assert!(!result.failed.is_empty());
575    }
576
577    #[test]
578    fn test_widget_type_id() {
579        let plot = RadarPlot::default();
580        let id = Widget::type_id(&plot);
581        assert_eq!(id, TypeId::of::<RadarPlot>());
582    }
583
584    #[test]
585    fn test_widget_measure() {
586        let plot = RadarPlot::default();
587        let constraints = Constraints::tight(Size::new(100.0, 50.0));
588        let size = plot.measure(constraints);
589        assert!(size.width <= 40.0);
590        assert!(size.height <= 40.0);
591    }
592
593    #[test]
594    fn test_widget_layout() {
595        let mut plot = RadarPlot::default();
596        let bounds = Rect::new(10.0, 20.0, 30.0, 25.0);
597        let result = plot.layout(bounds);
598        assert_eq!(result.size.width, 30.0);
599        assert_eq!(result.size.height, 25.0);
600        assert_eq!(plot.bounds, bounds);
601    }
602
603    #[test]
604    fn test_widget_event() {
605        let mut plot = RadarPlot::default();
606        let result = plot.event(&Event::FocusIn);
607        assert!(result.is_none());
608    }
609
610    #[test]
611    fn test_widget_children() {
612        let plot = RadarPlot::default();
613        assert!(plot.children().is_empty());
614    }
615
616    #[test]
617    fn test_widget_children_mut() {
618        let mut plot = RadarPlot::default();
619        assert!(plot.children_mut().is_empty());
620    }
621
622    #[test]
623    fn test_brick_assertions() {
624        let plot = RadarPlot::default();
625        let assertions = plot.assertions();
626        assert!(!assertions.is_empty());
627    }
628
629    #[test]
630    fn test_brick_budget() {
631        let plot = RadarPlot::default();
632        let budget = plot.budget();
633        assert!(budget.total_ms > 0);
634    }
635
636    #[test]
637    fn test_brick_to_html() {
638        let plot = RadarPlot::default();
639        assert!(plot.to_html().is_empty());
640    }
641
642    #[test]
643    fn test_brick_to_css() {
644        let plot = RadarPlot::default();
645        assert!(plot.to_css().is_empty());
646    }
647
648    #[test]
649    fn test_clone() {
650        let axes = vec!["A".to_string(), "B".to_string()];
651        let series = RadarSeries::new("Test", vec![1.0, 2.0], Color::GREEN);
652        let plot = RadarPlot::new(axes)
653            .with_series(series)
654            .with_fill(false)
655            .with_grid(false)
656            .with_labels(false)
657            .with_fill_alpha(0.5);
658
659        let cloned = plot;
660        assert_eq!(cloned.axes.len(), 2);
661        assert_eq!(cloned.series.len(), 1);
662        assert!(!cloned.fill);
663        assert!(!cloned.show_grid);
664        assert!(!cloned.show_labels);
665    }
666
667    #[test]
668    fn test_debug() {
669        let plot = RadarPlot::default();
670        let debug_str = format!("{:?}", plot);
671        assert!(debug_str.contains("RadarPlot"));
672    }
673
674    #[test]
675    fn test_radar_series_clone() {
676        let series = RadarSeries::new("Test", vec![1.0, 2.0], Color::RED);
677        let cloned = series;
678        assert_eq!(cloned.name, "Test");
679        assert_eq!(cloned.values.len(), 2);
680    }
681
682    #[test]
683    fn test_radar_series_debug() {
684        let series = RadarSeries::new("Test", vec![1.0], Color::BLUE);
685        let debug_str = format!("{:?}", series);
686        assert!(debug_str.contains("RadarSeries"));
687    }
688
689    #[test]
690    fn test_default_values() {
691        let plot = RadarPlot::new(vec!["A".to_string()]);
692        assert!(plot.fill);
693        assert!((plot.fill_alpha - 0.3).abs() < 0.01);
694        assert!(plot.show_labels);
695        assert!(plot.show_grid);
696    }
697}