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    #[allow(clippy::field_reassign_with_default)]
530    fn test_scatter_verify() {
531        let mut scatter = ScatterPlot::default();
532        scatter.bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
533        assert!(scatter.verify().is_valid());
534    }
535
536    #[test]
537    #[allow(clippy::field_reassign_with_default)]
538    fn test_scatter_verify_small_bounds() {
539        let mut scatter = ScatterPlot::default();
540        scatter.bounds = Rect::new(0.0, 0.0, 5.0, 3.0);
541        let result = scatter.verify();
542        assert!(!result.is_valid());
543    }
544
545    #[test]
546    fn test_scatter_children() {
547        let scatter = ScatterPlot::default();
548        assert!(scatter.children().is_empty());
549    }
550
551    #[test]
552    fn test_scatter_layout() {
553        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
554        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
555        let result = scatter.layout(bounds);
556        assert!(result.size.width > 0.0);
557        assert!(result.size.height > 0.0);
558    }
559
560    #[test]
561    fn test_scatter_paint() {
562        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
563        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
564        scatter.layout(bounds);
565
566        let mut buffer = CellBuffer::new(60, 20);
567        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
568        scatter.paint(&mut canvas);
569    }
570
571    #[test]
572    fn test_scatter_with_all_markers() {
573        for marker in [
574            MarkerStyle::Dot,
575            MarkerStyle::Cross,
576            MarkerStyle::Circle,
577            MarkerStyle::Square,
578            MarkerStyle::Diamond,
579        ] {
580            let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]).with_marker(marker);
581            let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
582            scatter.layout(bounds);
583            let mut buffer = CellBuffer::new(60, 20);
584            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
585            scatter.paint(&mut canvas);
586        }
587    }
588
589    #[test]
590    fn test_scatter_with_color() {
591        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]).with_color(Color::RED);
592        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
593        scatter.layout(bounds);
594        let mut buffer = CellBuffer::new(60, 20);
595        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
596        scatter.paint(&mut canvas);
597    }
598
599    #[test]
600    fn test_scatter_with_color_gradient() {
601        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)])
602            .with_color_by(vec![0.0, 0.5, 1.0], Gradient::two(Color::BLUE, Color::RED));
603        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
604        scatter.layout(bounds);
605        let mut buffer = CellBuffer::new(60, 20);
606        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
607        scatter.paint(&mut canvas);
608    }
609
610    #[test]
611    fn test_scatter_with_axes() {
612        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (10.0, 10.0)])
613            .with_axes(true)
614            .with_x_axis(ScatterAxis {
615                label: Some("X Axis".to_string()),
616                min: Some(0.0),
617                max: Some(10.0),
618                ticks: 5,
619            })
620            .with_y_axis(ScatterAxis {
621                label: Some("Y Axis".to_string()),
622                min: Some(0.0),
623                max: Some(10.0),
624                ticks: 5,
625            });
626        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
627        scatter.layout(bounds);
628        let mut buffer = CellBuffer::new(80, 24);
629        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
630        scatter.paint(&mut canvas);
631    }
632
633    #[test]
634    fn test_scatter_y_range() {
635        let scatter = ScatterPlot::new(vec![(0.0, -5.0), (1.0, 10.0), (2.0, 3.0)]);
636        let (y_min, y_max) = scatter.y_range();
637        assert!(y_min <= -5.0);
638        assert!(y_max >= 10.0);
639    }
640
641    #[test]
642    fn test_scatter_y_range_empty() {
643        let scatter = ScatterPlot::new(vec![]);
644        let (y_min, y_max) = scatter.y_range();
645        assert_eq!(y_min, 0.0);
646        assert_eq!(y_max, 1.0);
647    }
648
649    #[test]
650    fn test_scatter_with_many_points() {
651        let points: Vec<(f64, f64)> = (0..100)
652            .map(|i| (i as f64, (i as f64 * 0.1).sin() * 10.0))
653            .collect();
654        let mut scatter = ScatterPlot::new(points);
655        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
656        scatter.layout(bounds);
657        let mut buffer = CellBuffer::new(80, 24);
658        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
659        scatter.paint(&mut canvas);
660    }
661
662    #[test]
663    fn test_scatter_axis_default() {
664        let axis = ScatterAxis::default();
665        assert!(axis.label.is_none());
666        assert!(axis.min.is_none());
667        assert!(axis.max.is_none());
668    }
669
670    #[test]
671    fn test_marker_style_default() {
672        let marker = MarkerStyle::default();
673        assert!(matches!(marker, MarkerStyle::Dot));
674    }
675
676    #[test]
677    fn test_gradient_interpolate() {
678        let gradient = Gradient::two(Color::BLACK, Color::WHITE);
679        let mid = gradient.sample(0.5);
680        // Color values are f32 in range 0-1 or 0-255 depending on implementation
681        // Just verify it's between start and end colors
682        assert!(mid.r > 0.0);
683        assert!(mid.g > 0.0);
684        assert!(mid.b > 0.0);
685    }
686
687    #[test]
688    fn test_scatter_brick_name() {
689        let scatter = ScatterPlot::default();
690        assert_eq!(scatter.brick_name(), "ScatterPlot");
691    }
692
693    #[test]
694    fn test_scatter_budget() {
695        let scatter = ScatterPlot::default();
696        let budget = scatter.budget();
697        assert!(budget.layout_ms > 0);
698    }
699
700    #[test]
701    fn test_scatter_to_html_css() {
702        let scatter = ScatterPlot::default();
703        assert!(scatter.to_html().is_empty());
704        assert!(scatter.to_css().is_empty());
705    }
706
707    #[test]
708    fn test_marker_style_triangle() {
709        let marker = MarkerStyle::Triangle;
710        assert_eq!(marker.char(), '△');
711    }
712
713    #[test]
714    fn test_marker_style_star() {
715        let marker = MarkerStyle::Star;
716        assert_eq!(marker.char(), '★');
717    }
718
719    #[test]
720    fn test_scatter_plot_with_axis_labels() {
721        let scatter = ScatterPlot::new(vec![(1.0, 2.0), (3.0, 4.0)])
722            .with_x_axis(ScatterAxis {
723                label: Some("X-Axis".to_string()),
724                ..Default::default()
725            })
726            .with_y_axis(ScatterAxis {
727                label: Some("Y-Axis".to_string()),
728                ..Default::default()
729            });
730        assert!(scatter.x_axis.label.is_some());
731        assert!(scatter.y_axis.label.is_some());
732    }
733
734    #[test]
735    fn test_scatter_plot_with_diamond_marker() {
736        let scatter =
737            ScatterPlot::new(vec![(1.0, 2.0), (3.0, 4.0)]).with_marker(MarkerStyle::Diamond);
738        assert!(matches!(scatter.marker, MarkerStyle::Diamond));
739    }
740
741    #[test]
742    fn test_scatter_set_points() {
743        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0)]);
744        assert_eq!(scatter.points.len(), 1);
745        scatter.set_points(vec![(1.0, 1.0), (2.0, 2.0), (3.0, 3.0)]);
746        assert_eq!(scatter.points.len(), 3);
747    }
748
749    #[test]
750    fn test_scatter_with_axes_false() {
751        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (10.0, 10.0)]).with_axes(false);
752        assert!(!scatter.show_axes);
753        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
754        scatter.layout(bounds);
755        let mut buffer = CellBuffer::new(60, 20);
756        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
757        scatter.paint(&mut canvas);
758    }
759
760    #[test]
761    fn test_scatter_nan_values() {
762        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (f64::NAN, f64::NAN), (2.0, 2.0)]);
763        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
764        scatter.layout(bounds);
765        let mut buffer = CellBuffer::new(60, 20);
766        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
767        scatter.paint(&mut canvas); // Should not panic
768    }
769
770    #[test]
771    fn test_scatter_infinite_values() {
772        let mut scatter = ScatterPlot::new(vec![
773            (0.0, 0.0),
774            (f64::INFINITY, f64::NEG_INFINITY),
775            (2.0, 2.0),
776        ]);
777        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
778        scatter.layout(bounds);
779        let mut buffer = CellBuffer::new(60, 20);
780        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
781        scatter.paint(&mut canvas); // Should not panic
782    }
783
784    #[test]
785    fn test_scatter_color_range_no_color_by() {
786        let scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]);
787        let (c_min, c_max) = scatter.color_range();
788        assert_eq!(c_min, 0.0);
789        assert_eq!(c_max, 1.0);
790    }
791
792    #[test]
793    fn test_scatter_color_range_with_values() {
794        let scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)]).with_color_by(
795            vec![5.0, 10.0, 15.0],
796            Gradient::two(Color::BLUE, Color::RED),
797        );
798        let (c_min, c_max) = scatter.color_range();
799        assert_eq!(c_min, 5.0);
800        assert_eq!(c_max, 15.0);
801    }
802
803    #[test]
804    fn test_scatter_color_range_empty_values() {
805        let scatter = ScatterPlot::new(vec![(0.0, 0.0)])
806            .with_color_by(vec![], Gradient::two(Color::BLUE, Color::RED));
807        let (c_min, c_max) = scatter.color_range();
808        // Empty values results in default range
809        assert_eq!(c_min, 0.0);
810        assert_eq!(c_max, 1.0);
811    }
812
813    #[test]
814    fn test_scatter_color_by_fewer_values_than_points() {
815        // More points than color values - fallback to default color
816        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)])
817            .with_color_by(vec![0.0, 1.0], Gradient::two(Color::BLUE, Color::RED));
818        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
819        scatter.layout(bounds);
820        let mut buffer = CellBuffer::new(60, 20);
821        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
822        scatter.paint(&mut canvas);
823    }
824
825    #[test]
826    fn test_scatter_same_x_values() {
827        // When all x values are the same
828        let mut scatter = ScatterPlot::new(vec![(5.0, 0.0), (5.0, 5.0), (5.0, 10.0)]);
829        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
830        scatter.layout(bounds);
831        let (x_min, x_max) = scatter.x_range();
832        // Same x values, with padding
833        let mut buffer = CellBuffer::new(60, 20);
834        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
835        scatter.paint(&mut canvas);
836        let _ = (x_min, x_max);
837    }
838
839    #[test]
840    fn test_scatter_same_y_values() {
841        // When all y values are the same
842        let mut scatter = ScatterPlot::new(vec![(0.0, 5.0), (5.0, 5.0), (10.0, 5.0)]);
843        let bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
844        scatter.layout(bounds);
845        let mut buffer = CellBuffer::new(60, 20);
846        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
847        scatter.paint(&mut canvas);
848    }
849
850    #[test]
851    fn test_scatter_too_small_bounds() {
852        let mut scatter = ScatterPlot::new(vec![(0.0, 0.0), (1.0, 1.0)]);
853        let bounds = Rect::new(0.0, 0.0, 5.0, 3.0);
854        scatter.layout(bounds);
855        let mut buffer = CellBuffer::new(5, 3);
856        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
857        scatter.paint(&mut canvas); // Should early return
858    }
859
860    #[test]
861    fn test_scatter_children_mut() {
862        let mut scatter = ScatterPlot::default();
863        assert!(scatter.children_mut().is_empty());
864    }
865
866    #[test]
867    fn test_scatter_measure() {
868        let scatter = ScatterPlot::default();
869        let size = scatter.measure(Constraints {
870            min_width: 0.0,
871            min_height: 0.0,
872            max_width: 100.0,
873            max_height: 50.0,
874        });
875        assert_eq!(size.width, 60.0);
876        assert_eq!(size.height, 20.0);
877    }
878
879    #[test]
880    fn test_scatter_clone() {
881        let original = ScatterPlot::new(vec![(1.0, 2.0), (3.0, 4.0)])
882            .with_marker(MarkerStyle::Star)
883            .with_color(Color::GREEN);
884        let cloned = original;
885        assert_eq!(cloned.points.len(), 2);
886        assert_eq!(cloned.color, Color::GREEN);
887        assert!(matches!(cloned.marker, MarkerStyle::Star));
888    }
889
890    #[test]
891    fn test_scatter_debug() {
892        let scatter = ScatterPlot::default();
893        let debug = format!("{:?}", scatter);
894        assert!(debug.contains("ScatterPlot"));
895    }
896}