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::f64::consts::PI;
11use std::time::Duration;
12
13#[derive(Debug, Clone)]
15pub struct RadarSeries {
16 pub name: String,
18 pub values: Vec<f64>,
20 pub color: Color,
22}
23
24impl RadarSeries {
25 #[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#[derive(Debug, Clone)]
38pub struct RadarPlot {
39 axes: Vec<String>,
41 series: Vec<RadarSeries>,
43 fill: bool,
45 fill_alpha: f32,
47 show_labels: bool,
49 show_grid: bool,
51 bounds: Rect,
53}
54
55impl RadarPlot {
56 #[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 #[must_use]
72 pub fn with_series(mut self, series: RadarSeries) -> Self {
73 self.series.push(series);
74 self
75 }
76
77 #[must_use]
79 pub fn with_fill(mut self, fill: bool) -> Self {
80 self.fill = fill;
81 self
82 }
83
84 #[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 #[must_use]
93 pub fn with_labels(mut self, show: bool) -> Self {
94 self.show_labels = show;
95 self
96 }
97
98 #[must_use]
100 pub fn with_grid(mut self, show: bool) -> Self {
101 self.show_grid = show;
102 self
103 }
104
105 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 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 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 if self.show_grid {
181 for level in [0.25, 0.5, 0.75, 1.0] {
182 let r = radius * level;
183 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 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 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 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 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 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 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 for &(x, y) in &points {
262 self.draw_point(canvas, x, y, "◆", &style);
263 }
264 }
265
266 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 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); 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 #[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 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 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 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); let mut buffer = CellBuffer::new(1, 1);
515 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
516 plot.paint(&mut canvas); }
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 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); }
546
547 #[test]
548 fn test_paint_empty_axes() {
549 let mut plot = RadarPlot::new(vec![]); 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); }
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); 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}