Skip to main content

presentar_terminal/widgets/
scatter_plot.rs

1//! Scatter plot widget with marker styles.
2//!
3//! Implements P201 from SPEC-024 Section 15.2.
4
5use crate::theme::Gradient;
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Marker style for scatter points.
14#[derive(Debug, Clone, Copy, Default)]
15pub enum MarkerStyle {
16    /// Single braille dot (default).
17    #[default]
18    Dot,
19    /// Plus sign (+).
20    Cross,
21    /// Circle (○).
22    Circle,
23    /// Square (□).
24    Square,
25    /// Diamond (◇).
26    Diamond,
27    /// Triangle (△).
28    Triangle,
29    /// Star (★).
30    Star,
31}
32
33impl MarkerStyle {
34    /// Get the Unicode character for this marker.
35    #[must_use]
36    pub const fn char(self) -> char {
37        match self {
38            Self::Dot => '•',
39            Self::Cross => '+',
40            Self::Circle => '○',
41            Self::Square => '□',
42            Self::Diamond => '◇',
43            Self::Triangle => '△',
44            Self::Star => '★',
45        }
46    }
47}
48
49/// Axis configuration.
50#[derive(Debug, Clone)]
51pub struct ScatterAxis {
52    /// Axis label.
53    pub label: Option<String>,
54    /// Minimum value (None = auto).
55    pub min: Option<f64>,
56    /// Maximum value (None = auto).
57    pub max: Option<f64>,
58    /// Number of tick marks.
59    pub ticks: usize,
60}
61
62impl Default for ScatterAxis {
63    fn default() -> Self {
64        Self {
65            label: None,
66            min: None,
67            max: None,
68            ticks: 5,
69        }
70    }
71}
72
73/// Scatter plot widget.
74#[derive(Debug, Clone)]
75pub struct ScatterPlot {
76    points: Vec<(f64, f64)>,
77    marker: MarkerStyle,
78    color: Color,
79    /// Optional values for color gradient.
80    color_by: Option<Vec<f64>>,
81    gradient: Option<Gradient>,
82    x_axis: ScatterAxis,
83    y_axis: ScatterAxis,
84    show_axes: bool,
85    bounds: Rect,
86}
87
88impl ScatterPlot {
89    /// Create a new scatter plot.
90    #[must_use]
91    pub fn new(points: Vec<(f64, f64)>) -> Self {
92        Self {
93            points,
94            marker: MarkerStyle::default(),
95            color: Color::new(0.3, 0.7, 1.0, 1.0),
96            color_by: None,
97            gradient: None,
98            x_axis: ScatterAxis::default(),
99            y_axis: ScatterAxis::default(),
100            show_axes: true,
101            bounds: Rect::default(),
102        }
103    }
104
105    /// Set marker style.
106    #[must_use]
107    pub fn with_marker(mut self, marker: MarkerStyle) -> Self {
108        self.marker = marker;
109        self
110    }
111
112    /// Set point color.
113    #[must_use]
114    pub fn with_color(mut self, color: Color) -> Self {
115        self.color = color;
116        self
117    }
118
119    /// Set color gradient based on values.
120    #[must_use]
121    pub fn with_color_by(mut self, values: Vec<f64>, gradient: Gradient) -> Self {
122        self.color_by = Some(values);
123        self.gradient = Some(gradient);
124        self
125    }
126
127    /// Set X axis configuration.
128    #[must_use]
129    pub fn with_x_axis(mut self, axis: ScatterAxis) -> Self {
130        self.x_axis = axis;
131        self
132    }
133
134    /// Set Y axis configuration.
135    #[must_use]
136    pub fn with_y_axis(mut self, axis: ScatterAxis) -> Self {
137        self.y_axis = axis;
138        self
139    }
140
141    /// Toggle axis display.
142    #[must_use]
143    pub fn with_axes(mut self, show: bool) -> Self {
144        self.show_axes = show;
145        self
146    }
147
148    /// Update points.
149    pub fn set_points(&mut self, points: Vec<(f64, f64)>) {
150        self.points = points;
151    }
152
153    /// Get X range from data.
154    fn x_range(&self) -> (f64, f64) {
155        if let (Some(min), Some(max)) = (self.x_axis.min, self.x_axis.max) {
156            return (min, max);
157        }
158
159        let mut x_min = f64::INFINITY;
160        let mut x_max = f64::NEG_INFINITY;
161
162        for &(x, _) in &self.points {
163            if x.is_finite() {
164                x_min = x_min.min(x);
165                x_max = x_max.max(x);
166            }
167        }
168
169        if x_min == f64::INFINITY {
170            (0.0, 1.0)
171        } else {
172            let padding = (x_max - x_min) * 0.05;
173            (
174                self.x_axis.min.unwrap_or(x_min - padding),
175                self.x_axis.max.unwrap_or(x_max + padding),
176            )
177        }
178    }
179
180    /// Get Y range from data.
181    fn y_range(&self) -> (f64, f64) {
182        if let (Some(min), Some(max)) = (self.y_axis.min, self.y_axis.max) {
183            return (min, max);
184        }
185
186        let mut y_min = f64::INFINITY;
187        let mut y_max = f64::NEG_INFINITY;
188
189        for &(_, y) in &self.points {
190            if y.is_finite() {
191                y_min = y_min.min(y);
192                y_max = y_max.max(y);
193            }
194        }
195
196        if y_min == f64::INFINITY {
197            (0.0, 1.0)
198        } else {
199            let padding = (y_max - y_min) * 0.05;
200            (
201                self.y_axis.min.unwrap_or(y_min - padding),
202                self.y_axis.max.unwrap_or(y_max + padding),
203            )
204        }
205    }
206
207    /// Get color value range.
208    fn color_range(&self) -> (f64, f64) {
209        if let Some(ref values) = self.color_by {
210            let mut c_min = f64::INFINITY;
211            let mut c_max = f64::NEG_INFINITY;
212
213            for &v in values {
214                if v.is_finite() {
215                    c_min = c_min.min(v);
216                    c_max = c_max.max(v);
217                }
218            }
219
220            if c_min == f64::INFINITY {
221                (0.0, 1.0)
222            } else {
223                (c_min, c_max)
224            }
225        } else {
226            (0.0, 1.0)
227        }
228    }
229
230    /// Draw Y-axis labels.
231    fn draw_y_axis(
232        &self,
233        canvas: &mut dyn Canvas,
234        y_min: f64,
235        y_max: f64,
236        plot_y: f32,
237        plot_height: f32,
238        label_style: &TextStyle,
239    ) {
240        for i in 0..=self.y_axis.ticks {
241            let t = i as f64 / self.y_axis.ticks as f64;
242            let y_val = y_min + (y_max - y_min) * (1.0 - t);
243            let y_pos = plot_y + plot_height * t as f32;
244
245            if y_pos >= plot_y && y_pos < plot_y + plot_height {
246                let label = format!("{y_val:>5.0}");
247                canvas.draw_text(&label, Point::new(self.bounds.x, y_pos), label_style);
248            }
249        }
250    }
251
252    /// Draw X-axis labels.
253    #[allow(clippy::too_many_arguments)]
254    fn draw_x_axis(
255        &self,
256        canvas: &mut dyn Canvas,
257        x_min: f64,
258        x_max: f64,
259        plot_x: f32,
260        plot_y: f32,
261        plot_width: f32,
262        plot_height: f32,
263        label_style: &TextStyle,
264    ) {
265        for i in 0..=self.x_axis.ticks.min(plot_width as usize / 8) {
266            let t = i as f64 / self.x_axis.ticks as f64;
267            let x_val = x_min + (x_max - x_min) * t;
268            let x_pos = plot_x + plot_width * t as f32;
269
270            if x_pos >= plot_x && x_pos < plot_x + plot_width - 4.0 {
271                let label = format!("{x_val:.0}");
272                canvas.draw_text(&label, Point::new(x_pos, plot_y + plot_height), label_style);
273            }
274        }
275    }
276
277    /// Get color for a point at given index.
278    fn point_color(&self, i: usize, c_min: f64, c_max: f64) -> Color {
279        if let (Some(ref values), Some(ref gradient)) = (&self.color_by, &self.gradient) {
280            if i < values.len() {
281                let c_norm = if c_max > c_min {
282                    (values[i] - c_min) / (c_max - c_min)
283                } else {
284                    0.5
285                };
286                return gradient.sample(c_norm);
287            }
288        }
289        self.color
290    }
291}
292
293impl Default for ScatterPlot {
294    fn default() -> Self {
295        Self::new(Vec::new())
296    }
297}
298
299impl Widget for ScatterPlot {
300    fn type_id(&self) -> TypeId {
301        TypeId::of::<Self>()
302    }
303
304    fn measure(&self, constraints: Constraints) -> Size {
305        Size::new(
306            constraints.max_width.min(60.0),
307            constraints.max_height.min(20.0),
308        )
309    }
310
311    fn layout(&mut self, bounds: Rect) -> LayoutResult {
312        self.bounds = bounds;
313        LayoutResult {
314            size: Size::new(bounds.width, bounds.height),
315        }
316    }
317
318    #[allow(clippy::too_many_lines)]
319    fn paint(&self, canvas: &mut dyn Canvas) {
320        if self.bounds.width < 10.0 || self.bounds.height < 5.0 {
321            return;
322        }
323
324        let (x_min, x_max) = self.x_range();
325        let (y_min, y_max) = self.y_range();
326        let (c_min, c_max) = self.color_range();
327
328        // Calculate margins
329        let margin_left = if self.show_axes { 6.0 } else { 0.0 };
330        let margin_bottom = if self.show_axes { 2.0 } else { 0.0 };
331
332        let plot_x = self.bounds.x + margin_left;
333        let plot_y = self.bounds.y;
334        let plot_width = self.bounds.width - margin_left;
335        let plot_height = self.bounds.height - margin_bottom;
336
337        if plot_width <= 0.0 || plot_height <= 0.0 {
338            return;
339        }
340
341        let label_style = TextStyle {
342            color: Color::new(0.6, 0.6, 0.6, 1.0),
343            ..Default::default()
344        };
345
346        // Draw axis labels
347        if self.show_axes {
348            self.draw_y_axis(canvas, y_min, y_max, plot_y, plot_height, &label_style);
349            self.draw_x_axis(
350                canvas,
351                x_min,
352                x_max,
353                plot_x,
354                plot_y,
355                plot_width,
356                plot_height,
357                &label_style,
358            );
359        }
360
361        // Draw points
362        let marker_char = self.marker.char();
363
364        for (i, &(x, y)) in self.points.iter().enumerate() {
365            if !x.is_finite() || !y.is_finite() {
366                continue;
367            }
368
369            // Normalize coordinates
370            let x_norm = if x_max > x_min {
371                (x - x_min) / (x_max - x_min)
372            } else {
373                0.5
374            };
375            let y_norm = if y_max > y_min {
376                (y - y_min) / (y_max - y_min)
377            } else {
378                0.5
379            };
380
381            // Convert to screen coordinates
382            let screen_x = plot_x + (x_norm * plot_width as f64) as f32;
383            let screen_y = plot_y + ((1.0 - y_norm) * plot_height as f64) as f32;
384
385            // Check bounds
386            if screen_x < plot_x
387                || screen_x >= plot_x + plot_width
388                || screen_y < plot_y
389                || screen_y >= plot_y + plot_height
390            {
391                continue;
392            }
393
394            let style = TextStyle {
395                color: self.point_color(i, c_min, c_max),
396                ..Default::default()
397            };
398
399            canvas.draw_text(
400                &marker_char.to_string(),
401                Point::new(screen_x, screen_y),
402                &style,
403            );
404        }
405
406        // Draw axis labels if present
407        if self.show_axes {
408            if let Some(ref label) = self.x_axis.label {
409                let x = plot_x + plot_width / 2.0 - label.len() as f32 / 2.0;
410                canvas.draw_text(
411                    label,
412                    Point::new(x, self.bounds.y + self.bounds.height - 1.0),
413                    &label_style,
414                );
415            }
416
417            if let Some(ref label) = self.y_axis.label {
418                // Vertical label (first char only in terminal)
419                canvas.draw_text(
420                    &label.chars().next().unwrap_or(' ').to_string(),
421                    Point::new(self.bounds.x, plot_y + plot_height / 2.0),
422                    &label_style,
423                );
424            }
425        }
426    }
427
428    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
429        None
430    }
431
432    fn children(&self) -> &[Box<dyn Widget>] {
433        &[]
434    }
435
436    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
437        &mut []
438    }
439}
440
441impl Brick for ScatterPlot {
442    fn brick_name(&self) -> &'static str {
443        "ScatterPlot"
444    }
445
446    fn assertions(&self) -> &[BrickAssertion] {
447        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
448        ASSERTIONS
449    }
450
451    fn budget(&self) -> BrickBudget {
452        BrickBudget::uniform(16)
453    }
454
455    fn verify(&self) -> BrickVerification {
456        let mut passed = Vec::new();
457        let mut failed = Vec::new();
458
459        if self.bounds.width >= 10.0 && self.bounds.height >= 5.0 {
460            passed.push(BrickAssertion::max_latency_ms(16));
461        } else {
462            failed.push((
463                BrickAssertion::max_latency_ms(16),
464                "Size too small".to_string(),
465            ));
466        }
467
468        BrickVerification {
469            passed,
470            failed,
471            verification_time: Duration::from_micros(5),
472        }
473    }
474
475    fn to_html(&self) -> String {
476        String::new()
477    }
478
479    fn to_css(&self) -> String {
480        String::new()
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::direct::{CellBuffer, DirectTerminalCanvas};
488
489    #[test]
490    fn test_scatter_creation() {
491        let points = vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)];
492        let scatter = ScatterPlot::new(points);
493        assert_eq!(scatter.points.len(), 3);
494    }
495
496    #[test]
497    fn test_marker_chars() {
498        assert_eq!(MarkerStyle::Dot.char(), '•');
499        assert_eq!(MarkerStyle::Cross.char(), '+');
500        assert_eq!(MarkerStyle::Circle.char(), '○');
501        assert_eq!(MarkerStyle::Square.char(), '□');
502        assert_eq!(MarkerStyle::Diamond.char(), '◇');
503    }
504
505    #[test]
506    fn test_empty_scatter() {
507        let scatter = ScatterPlot::new(vec![]);
508        let (x_min, x_max) = scatter.x_range();
509        assert_eq!(x_min, 0.0);
510        assert_eq!(x_max, 1.0);
511    }
512
513    #[test]
514    fn test_auto_range() {
515        let points = vec![(10.0, 20.0), (30.0, 40.0)];
516        let scatter = ScatterPlot::new(points);
517        let (x_min, x_max) = scatter.x_range();
518        assert!(x_min < 10.0); // Includes padding
519        assert!(x_max > 30.0);
520    }
521
522    #[test]
523    fn test_scatter_assertions() {
524        let scatter = ScatterPlot::default();
525        assert!(!scatter.assertions().is_empty());
526    }
527
528    #[test]
529    fn test_scatter_verify() {
530        let mut scatter = ScatterPlot::default();
531        scatter.bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
532        assert!(scatter.verify().is_valid());
533    }
534
535    #[test]
536    fn test_scatter_verify_small_bounds() {
537        let mut scatter = ScatterPlot::default();
538        scatter.bounds = Rect::new(0.0, 0.0, 5.0, 3.0);
539        let result = scatter.verify();
540        assert!(!result.is_valid());
541    }
542
543    #[test]
544    fn test_scatter_children() {
545        let scatter = ScatterPlot::default();
546        assert!(scatter.children().is_empty());
547    }
548
549    #[test]
550    fn test_scatter_layout() {
551        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
552        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
553        let result = scatter.layout(bounds);
554        assert!(result.size.width > 0.0);
555        assert!(result.size.height > 0.0);
556    }
557
558    #[test]
559    fn test_scatter_paint() {
560        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
561        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
562        scatter.layout(bounds);
563
564        let mut buffer = CellBuffer::new(60, 20);
565        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
566        scatter.paint(&mut canvas);
567    }
568
569    #[test]
570    fn test_scatter_with_all_markers() {
571        for marker in [
572            MarkerStyle::Dot,
573            MarkerStyle::Cross,
574            MarkerStyle::Circle,
575            MarkerStyle::Square,
576            MarkerStyle::Diamond,
577        ] {
578            let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]).with_marker(marker);
579            let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
580            scatter.layout(bounds);
581            let mut buffer = CellBuffer::new(60, 20);
582            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
583            scatter.paint(&mut canvas);
584        }
585    }
586
587    #[test]
588    fn test_scatter_with_color() {
589        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]).with_color(Color::RED);
590        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
591        scatter.layout(bounds);
592        let mut buffer = CellBuffer::new(60, 20);
593        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
594        scatter.paint(&mut canvas);
595    }
596
597    #[test]
598    fn test_scatter_with_color_gradient() {
599        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)])
600            .with_color_by(vec![0.0, 0.5, 1.0], Gradient::two(Color::BLUE, Color::RED));
601        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
602        scatter.layout(bounds);
603        let mut buffer = CellBuffer::new(60, 20);
604        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
605        scatter.paint(&mut canvas);
606    }
607
608    #[test]
609    fn test_scatter_with_axes() {
610        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (10.0, 10.0)])
611            .with_axes(true)
612            .with_x_axis(ScatterAxis {
613                label: Some("X Axis".to_string()),
614                min: Some(0.0),
615                max: Some(10.0),
616                ticks: 5,
617            })
618            .with_y_axis(ScatterAxis {
619                label: Some("Y Axis".to_string()),
620                min: Some(0.0),
621                max: Some(10.0),
622                ticks: 5,
623            });
624        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
625        scatter.layout(bounds);
626        let mut buffer = CellBuffer::new(80, 24);
627        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
628        scatter.paint(&mut canvas);
629    }
630
631    #[test]
632    fn test_scatter_y_range() {
633        let scatter = ScatterPlot::new(vec![(0.0, -5.0), (1.0, 10.0), (2.0, 3.0)]);
634        let (y_min, y_max) = scatter.y_range();
635        assert!(y_min <= -5.0);
636        assert!(y_max >= 10.0);
637    }
638
639    #[test]
640    fn test_scatter_y_range_empty() {
641        let scatter = ScatterPlot::new(vec![]);
642        let (y_min, y_max) = scatter.y_range();
643        assert_eq!(y_min, 0.0);
644        assert_eq!(y_max, 1.0);
645    }
646
647    #[test]
648    fn test_scatter_with_many_points() {
649        let points: Vec<(f64, f64)> = (0..100)
650            .map(|i| (i as f64, (i as f64 * 0.1).sin() * 10.0))
651            .collect();
652        let mut scatter = ScatterPlot::new(points);
653        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
654        scatter.layout(bounds);
655        let mut buffer = CellBuffer::new(80, 24);
656        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
657        scatter.paint(&mut canvas);
658    }
659
660    #[test]
661    fn test_scatter_axis_default() {
662        let axis = ScatterAxis::default();
663        assert!(axis.label.is_none());
664        assert!(axis.min.is_none());
665        assert!(axis.max.is_none());
666    }
667
668    #[test]
669    fn test_marker_style_default() {
670        let marker = MarkerStyle::default();
671        assert!(matches!(marker, MarkerStyle::Dot));
672    }
673
674    #[test]
675    fn test_gradient_interpolate() {
676        let gradient = Gradient::two(Color::BLACK, Color::WHITE);
677        let mid = gradient.sample(0.5);
678        // Color values are f32 in range 0-1 or 0-255 depending on implementation
679        // Just verify it's between start and end colors
680        assert!(mid.r > 0.0);
681        assert!(mid.g > 0.0);
682        assert!(mid.b > 0.0);
683    }
684
685    #[test]
686    fn test_scatter_brick_name() {
687        let scatter = ScatterPlot::default();
688        assert_eq!(scatter.brick_name(), "ScatterPlot");
689    }
690
691    #[test]
692    fn test_scatter_budget() {
693        let scatter = ScatterPlot::default();
694        let budget = scatter.budget();
695        assert!(budget.layout_ms > 0);
696    }
697
698    #[test]
699    fn test_scatter_to_html_css() {
700        let scatter = ScatterPlot::default();
701        assert!(scatter.to_html().is_empty());
702        assert!(scatter.to_css().is_empty());
703    }
704
705    #[test]
706    fn test_marker_style_triangle() {
707        let marker = MarkerStyle::Triangle;
708        assert_eq!(marker.char(), '△');
709    }
710
711    #[test]
712    fn test_marker_style_star() {
713        let marker = MarkerStyle::Star;
714        assert_eq!(marker.char(), '★');
715    }
716
717    #[test]
718    fn test_scatter_plot_with_axis_labels() {
719        let scatter = ScatterPlot::new(vec![(1.0, 2.0), (3.0, 4.0)])
720            .with_x_axis(ScatterAxis {
721                label: Some("X-Axis".to_string()),
722                ..Default::default()
723            })
724            .with_y_axis(ScatterAxis {
725                label: Some("Y-Axis".to_string()),
726                ..Default::default()
727            });
728        assert!(scatter.x_axis.label.is_some());
729        assert!(scatter.y_axis.label.is_some());
730    }
731
732    #[test]
733    fn test_scatter_plot_with_diamond_marker() {
734        let scatter =
735            ScatterPlot::new(vec![(1.0, 2.0), (3.0, 4.0)]).with_marker(MarkerStyle::Diamond);
736        assert!(matches!(scatter.marker, MarkerStyle::Diamond));
737    }
738
739    #[test]
740    fn test_scatter_set_points() {
741        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0)]);
742        assert_eq!(scatter.points.len(), 1);
743        scatter.set_points(vec![(1.0, 1.0), (2.0, 2.0), (3.0, 3.0)]);
744        assert_eq!(scatter.points.len(), 3);
745    }
746
747    #[test]
748    fn test_scatter_with_axes_false() {
749        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (10.0, 10.0)]).with_axes(false);
750        assert!(!scatter.show_axes);
751        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
752        scatter.layout(bounds);
753        let mut buffer = CellBuffer::new(60, 20);
754        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
755        scatter.paint(&mut canvas);
756    }
757
758    #[test]
759    fn test_scatter_nan_values() {
760        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (f64::NAN, f64::NAN), (2.0, 2.0)]);
761        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
762        scatter.layout(bounds);
763        let mut buffer = CellBuffer::new(60, 20);
764        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
765        scatter.paint(&mut canvas); // Should not panic
766    }
767
768    #[test]
769    fn test_scatter_infinite_values() {
770        let mut scatter = ScatterPlot::new(vec![
771            (0.0, 0.0),
772            (f64::INFINITY, f64::NEG_INFINITY),
773            (2.0, 2.0),
774        ]);
775        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
776        scatter.layout(bounds);
777        let mut buffer = CellBuffer::new(60, 20);
778        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
779        scatter.paint(&mut canvas); // Should not panic
780    }
781
782    #[test]
783    fn test_scatter_color_range_no_color_by() {
784        let scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]);
785        let (c_min, c_max) = scatter.color_range();
786        assert_eq!(c_min, 0.0);
787        assert_eq!(c_max, 1.0);
788    }
789
790    #[test]
791    fn test_scatter_color_range_with_values() {
792        let scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)]).with_color_by(
793            vec![5.0, 10.0, 15.0],
794            Gradient::two(Color::BLUE, Color::RED),
795        );
796        let (c_min, c_max) = scatter.color_range();
797        assert_eq!(c_min, 5.0);
798        assert_eq!(c_max, 15.0);
799    }
800
801    #[test]
802    fn test_scatter_color_range_empty_values() {
803        let scatter = ScatterPlot::new(vec![(0.0, 0.0)])
804            .with_color_by(vec![], Gradient::two(Color::BLUE, Color::RED));
805        let (c_min, c_max) = scatter.color_range();
806        // Empty values results in default range
807        assert_eq!(c_min, 0.0);
808        assert_eq!(c_max, 1.0);
809    }
810
811    #[test]
812    fn test_scatter_color_by_fewer_values_than_points() {
813        // More points than color values - fallback to default color
814        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)])
815            .with_color_by(vec![0.0, 1.0], Gradient::two(Color::BLUE, Color::RED));
816        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
817        scatter.layout(bounds);
818        let mut buffer = CellBuffer::new(60, 20);
819        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
820        scatter.paint(&mut canvas);
821    }
822
823    #[test]
824    fn test_scatter_same_x_values() {
825        // When all x values are the same
826        let mut scatter = ScatterPlot::new(vec![(5.0, 0.0), (5.0, 5.0), (5.0, 10.0)]);
827        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
828        scatter.layout(bounds);
829        let (x_min, x_max) = scatter.x_range();
830        // Same x values, with padding
831        let mut buffer = CellBuffer::new(60, 20);
832        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
833        scatter.paint(&mut canvas);
834        let _ = (x_min, x_max);
835    }
836
837    #[test]
838    fn test_scatter_same_y_values() {
839        // When all y values are the same
840        let mut scatter = ScatterPlot::new(vec![(0.0, 5.0), (5.0, 5.0), (10.0, 5.0)]);
841        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
842        scatter.layout(bounds);
843        let mut buffer = CellBuffer::new(60, 20);
844        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
845        scatter.paint(&mut canvas);
846    }
847
848    #[test]
849    fn test_scatter_too_small_bounds() {
850        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]);
851        let bounds = Rect::new(0.0, 0.0, 5.0, 3.0);
852        scatter.layout(bounds);
853        let mut buffer = CellBuffer::new(5, 3);
854        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
855        scatter.paint(&mut canvas); // Should early return
856    }
857
858    #[test]
859    fn test_scatter_children_mut() {
860        let mut scatter = ScatterPlot::default();
861        assert!(scatter.children_mut().is_empty());
862    }
863
864    #[test]
865    fn test_scatter_measure() {
866        let scatter = ScatterPlot::default();
867        let size = scatter.measure(Constraints {
868            min_width: 0.0,
869            min_height: 0.0,
870            max_width: 100.0,
871            max_height: 50.0,
872        });
873        assert_eq!(size.width, 60.0);
874        assert_eq!(size.height, 20.0);
875    }
876
877    #[test]
878    fn test_scatter_clone() {
879        let original = ScatterPlot::new(vec![(1.0, 2.0), (3.0, 4.0)])
880            .with_marker(MarkerStyle::Star)
881            .with_color(Color::GREEN);
882        let cloned = original.clone();
883        assert_eq!(cloned.points.len(), 2);
884        assert_eq!(cloned.color, Color::GREEN);
885        assert!(matches!(cloned.marker, MarkerStyle::Star));
886    }
887
888    #[test]
889    fn test_scatter_debug() {
890        let scatter = ScatterPlot::default();
891        let debug = format!("{:?}", scatter);
892        assert!(debug.contains("ScatterPlot"));
893    }
894}