Skip to main content

presentar_terminal/widgets/
line_chart.rs

1//! Multi-series line chart widget with axis support.
2//!
3//! Implements P200 from SPEC-024 Section 15.2.
4
5use crate::widgets::symbols::BRAILLE_UP;
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/// Line simplification algorithm.
14#[derive(Debug, Clone, Copy, Default)]
15pub enum Simplification {
16    /// No simplification - render all points.
17    #[default]
18    None,
19    /// Douglas-Peucker algorithm with epsilon threshold.
20    DouglasPeucker { epsilon: f64 },
21    /// Visvalingam-Whyatt algorithm with area threshold.
22    VisvalingamWhyatt { threshold: f64 },
23}
24
25/// A single data series for the line chart.
26#[derive(Debug, Clone)]
27pub struct Series {
28    /// Series name (for legend).
29    pub name: String,
30    /// Data points as (x, y) coordinates.
31    pub data: Vec<(f64, f64)>,
32    /// Series color.
33    pub color: Color,
34    /// Line style.
35    pub style: LineStyle,
36}
37
38/// Line rendering style.
39#[derive(Debug, Clone, Copy, Default)]
40pub enum LineStyle {
41    /// Solid line (default).
42    #[default]
43    Solid,
44    /// Dashed line.
45    Dashed,
46    /// Dotted line.
47    Dotted,
48    /// Show only markers.
49    Markers,
50}
51
52/// Axis configuration.
53#[derive(Debug, Clone)]
54pub struct Axis {
55    /// Axis label.
56    pub label: Option<String>,
57    /// Minimum value (None = auto).
58    pub min: Option<f64>,
59    /// Maximum value (None = auto).
60    pub max: Option<f64>,
61    /// Number of tick marks.
62    pub ticks: usize,
63    /// Show grid lines.
64    pub grid: bool,
65}
66
67impl Default for Axis {
68    fn default() -> Self {
69        Self {
70            label: None,
71            min: None,
72            max: None,
73            ticks: 5,
74            grid: false,
75        }
76    }
77}
78
79/// Legend position.
80#[derive(Debug, Clone, Copy, Default)]
81pub enum LegendPosition {
82    /// Top-right corner.
83    #[default]
84    TopRight,
85    /// Top-left corner.
86    TopLeft,
87    /// Bottom-right corner.
88    BottomRight,
89    /// Bottom-left corner.
90    BottomLeft,
91    /// No legend.
92    None,
93}
94
95/// Multi-series line chart widget.
96#[derive(Debug, Clone)]
97pub struct LineChart {
98    series: Vec<Series>,
99    x_axis: Axis,
100    y_axis: Axis,
101    legend: LegendPosition,
102    simplification: Simplification,
103    bounds: Rect,
104    /// Margin for axis labels.
105    margin_left: f32,
106    margin_bottom: f32,
107}
108
109impl LineChart {
110    /// Create a new empty line chart.
111    #[must_use]
112    pub fn new() -> Self {
113        Self {
114            series: Vec::new(),
115            x_axis: Axis::default(),
116            y_axis: Axis::default(),
117            legend: LegendPosition::default(),
118            simplification: Simplification::default(),
119            bounds: Rect::default(),
120            margin_left: 6.0,
121            margin_bottom: 2.0,
122        }
123    }
124
125    /// Add a data series.
126    #[must_use]
127    pub fn add_series(mut self, name: &str, data: Vec<(f64, f64)>, color: Color) -> Self {
128        self.series.push(Series {
129            name: name.to_string(),
130            data,
131            color,
132            style: LineStyle::default(),
133        });
134        self
135    }
136
137    /// Add a series with custom style.
138    #[must_use]
139    pub fn add_series_styled(
140        mut self,
141        name: &str,
142        data: Vec<(f64, f64)>,
143        color: Color,
144        style: LineStyle,
145    ) -> Self {
146        self.series.push(Series {
147            name: name.to_string(),
148            data,
149            color,
150            style,
151        });
152        self
153    }
154
155    /// Set line simplification algorithm.
156    #[must_use]
157    pub fn with_simplification(mut self, algorithm: Simplification) -> Self {
158        self.simplification = algorithm;
159        self
160    }
161
162    /// Set X axis configuration.
163    #[must_use]
164    pub fn with_x_axis(mut self, axis: Axis) -> Self {
165        self.x_axis = axis;
166        self
167    }
168
169    /// Set Y axis configuration.
170    #[must_use]
171    pub fn with_y_axis(mut self, axis: Axis) -> Self {
172        self.y_axis = axis;
173        self
174    }
175
176    /// UX-P01: Compact mode with no axis labels or margins.
177    ///
178    /// Useful for inline/small charts where space is limited.
179    #[must_use]
180    pub fn compact(mut self) -> Self {
181        self.margin_left = 0.0;
182        self.margin_bottom = 0.0;
183        self.y_axis.ticks = 0;
184        self.x_axis.ticks = 0;
185        self.legend = LegendPosition::None;
186        self
187    }
188
189    /// UX-P01: Set custom margins for axis labels.
190    #[must_use]
191    pub fn with_margins(mut self, left: f32, bottom: f32) -> Self {
192        debug_assert!(left >= 0.0, "left margin must be non-negative");
193        debug_assert!(bottom >= 0.0, "bottom margin must be non-negative");
194        self.margin_left = left;
195        self.margin_bottom = bottom;
196        self
197    }
198
199    /// Set legend position.
200    #[must_use]
201    pub fn with_legend(mut self, position: LegendPosition) -> Self {
202        self.legend = position;
203        self
204    }
205
206    /// Compute X range from all series.
207    fn x_range(&self) -> (f64, f64) {
208        if let Some(min) = self.x_axis.min {
209            if let Some(max) = self.x_axis.max {
210                return (min, max);
211            }
212        }
213
214        let mut x_min = f64::INFINITY;
215        let mut x_max = f64::NEG_INFINITY;
216
217        for series in &self.series {
218            for &(x, _) in &series.data {
219                if x.is_finite() {
220                    x_min = x_min.min(x);
221                    x_max = x_max.max(x);
222                }
223            }
224        }
225
226        if x_min == f64::INFINITY {
227            (0.0, 1.0)
228        } else {
229            (
230                self.x_axis.min.unwrap_or(x_min),
231                self.x_axis.max.unwrap_or(x_max),
232            )
233        }
234    }
235
236    /// Compute Y range from all series.
237    fn y_range(&self) -> (f64, f64) {
238        if let Some(min) = self.y_axis.min {
239            if let Some(max) = self.y_axis.max {
240                return (min, max);
241            }
242        }
243
244        let mut y_min = f64::INFINITY;
245        let mut y_max = f64::NEG_INFINITY;
246
247        for series in &self.series {
248            for &(_, y) in &series.data {
249                if y.is_finite() {
250                    y_min = y_min.min(y);
251                    y_max = y_max.max(y);
252                }
253            }
254        }
255
256        if y_min == f64::INFINITY {
257            (0.0, 1.0)
258        } else {
259            // Add 10% padding
260            let padding = (y_max - y_min) * 0.1;
261            (
262                self.y_axis.min.unwrap_or(y_min - padding),
263                self.y_axis.max.unwrap_or(y_max + padding),
264            )
265        }
266    }
267
268    /// Apply simplification to a series.
269    fn simplify(&self, data: &[(f64, f64)]) -> Vec<(f64, f64)> {
270        match self.simplification {
271            Simplification::None => data.to_vec(),
272            Simplification::DouglasPeucker { epsilon } => douglas_peucker(data, epsilon),
273            Simplification::VisvalingamWhyatt { threshold } => visvalingam_whyatt(data, threshold),
274        }
275    }
276
277    /// Draw Y axis labels.
278    fn draw_y_axis(
279        &self,
280        canvas: &mut dyn Canvas,
281        y_min: f64,
282        y_max: f64,
283        plot_y: f32,
284        plot_height: f32,
285    ) {
286        let style = TextStyle {
287            color: Color::new(0.6, 0.6, 0.6, 1.0),
288            ..Default::default()
289        };
290        for i in 0..=self.y_axis.ticks {
291            let t = i as f64 / self.y_axis.ticks as f64;
292            let y_val = y_min + (y_max - y_min) * (1.0 - t);
293            let y_pos = plot_y + plot_height * t as f32;
294            if y_pos >= plot_y && y_pos < plot_y + plot_height {
295                canvas.draw_text(
296                    &format!("{y_val:>5.0}"),
297                    Point::new(self.bounds.x, y_pos),
298                    &style,
299                );
300            }
301        }
302    }
303
304    /// Draw X axis labels.
305    #[allow(clippy::too_many_arguments)]
306    fn draw_x_axis(
307        &self,
308        canvas: &mut dyn Canvas,
309        x_min: f64,
310        x_max: f64,
311        plot_x: f32,
312        plot_y: f32,
313        plot_width: f32,
314        plot_height: f32,
315    ) {
316        let style = TextStyle {
317            color: Color::new(0.6, 0.6, 0.6, 1.0),
318            ..Default::default()
319        };
320        for i in 0..=self.x_axis.ticks.min(plot_width as usize / 8) {
321            let t = i as f64 / self.x_axis.ticks as f64;
322            let x_val = x_min + (x_max - x_min) * t;
323            let x_pos = plot_x + plot_width * t as f32;
324            if x_pos >= plot_x && x_pos < plot_x + plot_width - 4.0 {
325                canvas.draw_text(
326                    &format!("{x_val:.0}"),
327                    Point::new(x_pos, plot_y + plot_height),
328                    &style,
329                );
330            }
331        }
332    }
333
334    /// Draw the legend.
335    fn draw_legend(
336        &self,
337        canvas: &mut dyn Canvas,
338        plot_x: f32,
339        plot_y: f32,
340        plot_width: f32,
341        plot_height: f32,
342    ) {
343        if matches!(self.legend, LegendPosition::None) || self.series.is_empty() {
344            return;
345        }
346        let legend_width = self
347            .series
348            .iter()
349            .map(|s| s.name.len() + 3)
350            .max()
351            .unwrap_or(10) as f32;
352        let (lx, ly) = match self.legend {
353            LegendPosition::TopRight => (plot_x + plot_width - legend_width, plot_y),
354            LegendPosition::TopLeft => (plot_x, plot_y),
355            LegendPosition::BottomRight => (
356                plot_x + plot_width - legend_width,
357                plot_y + plot_height - self.series.len() as f32,
358            ),
359            LegendPosition::BottomLeft => (plot_x, plot_y + plot_height - self.series.len() as f32),
360            LegendPosition::None => return,
361        };
362        for (i, series) in self.series.iter().enumerate() {
363            canvas.draw_text(
364                &format!("─ {}", series.name),
365                Point::new(lx, ly + i as f32),
366                &TextStyle {
367                    color: series.color,
368                    ..Default::default()
369                },
370            );
371        }
372    }
373}
374
375impl Default for LineChart {
376    fn default() -> Self {
377        Self::new()
378    }
379}
380
381impl Widget for LineChart {
382    fn type_id(&self) -> TypeId {
383        TypeId::of::<Self>()
384    }
385
386    fn measure(&self, constraints: Constraints) -> Size {
387        Size::new(
388            constraints.max_width.min(80.0),
389            constraints.max_height.min(20.0),
390        )
391    }
392
393    fn layout(&mut self, bounds: Rect) -> LayoutResult {
394        self.bounds = bounds;
395        LayoutResult {
396            size: Size::new(bounds.width, bounds.height),
397        }
398    }
399
400    fn paint(&self, canvas: &mut dyn Canvas) {
401        if self.bounds.width < 10.0 || self.bounds.height < 5.0 {
402            return;
403        }
404
405        let (x_min, x_max) = self.x_range();
406        let (y_min, y_max) = self.y_range();
407        let plot_x = self.bounds.x + self.margin_left;
408        let plot_y = self.bounds.y;
409        let plot_width = self.bounds.width - self.margin_left;
410        let plot_height = self.bounds.height - self.margin_bottom;
411        if plot_width <= 0.0 || plot_height <= 0.0 {
412            return;
413        }
414
415        // Draw axes
416        self.draw_y_axis(canvas, y_min, y_max, plot_y, plot_height);
417        self.draw_x_axis(
418            canvas,
419            x_min,
420            x_max,
421            plot_x,
422            plot_y,
423            plot_width,
424            plot_height,
425        );
426
427        // Draw each series
428        for series in &self.series {
429            let simplified = self.simplify(&series.data);
430            let style = TextStyle {
431                color: series.color,
432                ..Default::default()
433            };
434
435            // Create a grid to track which cells have been drawn
436            let cols = plot_width as usize;
437            let rows = (plot_height * 4.0) as usize; // 4 braille dots per row
438
439            if cols == 0 || rows == 0 {
440                continue;
441            }
442
443            let mut grid = vec![vec![false; rows]; cols];
444
445            // Plot points onto grid
446            for &(x, y) in &simplified {
447                if !x.is_finite() || !y.is_finite() {
448                    continue;
449                }
450
451                // Normalize to 0..1
452                let x_norm = if x_max > x_min {
453                    (x - x_min) / (x_max - x_min)
454                } else {
455                    0.5
456                };
457                let y_norm = if y_max > y_min {
458                    (y - y_min) / (y_max - y_min)
459                } else {
460                    0.5
461                };
462
463                // Convert to grid coordinates
464                let gx =
465                    ((x_norm * (cols - 1) as f64).round() as usize).min(cols.saturating_sub(1));
466                let gy = (((1.0 - y_norm) * (rows - 1) as f64).round() as usize)
467                    .min(rows.saturating_sub(1));
468
469                grid[gx][gy] = true;
470            }
471
472            // Connect adjacent points with lines (Bresenham-like)
473            let points: Vec<(usize, usize)> = simplified
474                .iter()
475                .filter_map(|&(x, y)| {
476                    if !x.is_finite() || !y.is_finite() {
477                        return None;
478                    }
479                    let x_norm = if x_max > x_min {
480                        (x - x_min) / (x_max - x_min)
481                    } else {
482                        0.5
483                    };
484                    let y_norm = if y_max > y_min {
485                        (y - y_min) / (y_max - y_min)
486                    } else {
487                        0.5
488                    };
489                    let gx =
490                        ((x_norm * (cols - 1) as f64).round() as usize).min(cols.saturating_sub(1));
491                    let gy = (((1.0 - y_norm) * (rows - 1) as f64).round() as usize)
492                        .min(rows.saturating_sub(1));
493                    Some((gx, gy))
494                })
495                .collect();
496
497            for window in points.windows(2) {
498                if let [p1, p2] = window {
499                    draw_line(&mut grid, p1.0, p1.1, p2.0, p2.1);
500                }
501            }
502
503            // Render grid as braille
504            let char_rows = plot_height as usize;
505            for cy in 0..char_rows {
506                #[allow(clippy::needless_range_loop)]
507                for cx in 0..cols {
508                    // Each braille char encodes 2x4 dots
509                    // But we're using 1x4 (single column) for simplicity
510                    let mut dots = 0u8;
511                    for dy in 0..4 {
512                        let gy = cy * 4 + dy;
513                        if gy < rows && grid[cx][gy] {
514                            dots |= 1 << dy;
515                        }
516                    }
517
518                    if dots > 0 {
519                        // Use braille patterns
520                        let braille_idx = dots as usize;
521                        let ch = if braille_idx < BRAILLE_UP.len() {
522                            BRAILLE_UP[braille_idx]
523                        } else {
524                            '⣿'
525                        };
526                        canvas.draw_text(
527                            &ch.to_string(),
528                            Point::new(plot_x + cx as f32, plot_y + cy as f32),
529                            &style,
530                        );
531                    }
532                }
533            }
534        }
535
536        // Draw legend
537        self.draw_legend(canvas, plot_x, plot_y, plot_width, plot_height);
538    }
539
540    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
541        None
542    }
543
544    fn children(&self) -> &[Box<dyn Widget>] {
545        &[]
546    }
547
548    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
549        &mut []
550    }
551}
552
553impl Brick for LineChart {
554    fn brick_name(&self) -> &'static str {
555        "LineChart"
556    }
557
558    fn assertions(&self) -> &[BrickAssertion] {
559        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
560        ASSERTIONS
561    }
562
563    fn budget(&self) -> BrickBudget {
564        BrickBudget::uniform(16)
565    }
566
567    fn verify(&self) -> BrickVerification {
568        let mut passed = Vec::new();
569        let mut failed = Vec::new();
570
571        if self.bounds.width >= 10.0 {
572            passed.push(BrickAssertion::max_latency_ms(16));
573        } else {
574            failed.push((
575                BrickAssertion::max_latency_ms(16),
576                "Width too small".to_string(),
577            ));
578        }
579
580        BrickVerification {
581            passed,
582            failed,
583            verification_time: Duration::from_micros(5),
584        }
585    }
586
587    fn to_html(&self) -> String {
588        String::new()
589    }
590
591    fn to_css(&self) -> String {
592        String::new()
593    }
594}
595
596/// Check if point is within grid bounds.
597#[inline]
598fn is_in_grid_bounds(x: isize, y: isize, cols: isize, rows: isize) -> bool {
599    x >= 0 && x < cols && y >= 0 && y < rows
600}
601
602/// Compute step direction for line drawing (-1 or 1).
603#[inline]
604fn line_step(from: usize, to: usize) -> isize {
605    if from < to {
606        1
607    } else {
608        -1
609    }
610}
611
612/// Draw a line between two points using Bresenham's algorithm.
613#[allow(clippy::cast_possible_wrap)]
614fn draw_line(grid: &mut [Vec<bool>], x0: usize, y0: usize, x1: usize, y1: usize) {
615    let dx = (x1 as isize - x0 as isize).abs();
616    let dy = -(y1 as isize - y0 as isize).abs();
617    let sx = line_step(x0, x1);
618    let sy = line_step(y0, y1);
619    let mut err = dx + dy;
620
621    let mut x = x0 as isize;
622    let mut y = y0 as isize;
623
624    let cols = grid.len() as isize;
625    let rows = if cols > 0 { grid[0].len() as isize } else { 0 };
626
627    loop {
628        if is_in_grid_bounds(x, y, cols, rows) {
629            grid[x as usize][y as usize] = true;
630        }
631
632        if x == x1 as isize && y == y1 as isize {
633            break;
634        }
635
636        let e2 = 2 * err;
637        if e2 >= dy {
638            err += dy;
639            x += sx;
640        }
641        if e2 <= dx {
642            err += dx;
643            y += sy;
644        }
645    }
646}
647
648/// Douglas-Peucker line simplification algorithm.
649fn douglas_peucker(points: &[(f64, f64)], epsilon: f64) -> Vec<(f64, f64)> {
650    if points.len() < 3 {
651        return points.to_vec();
652    }
653
654    // Find the point with the maximum distance from the line
655    let start = points[0];
656    let end = points[points.len() - 1];
657
658    let mut max_dist = 0.0;
659    let mut max_idx = 0;
660
661    for (i, &point) in points.iter().enumerate().skip(1).take(points.len() - 2) {
662        let dist = perpendicular_distance(point, start, end);
663        if dist > max_dist {
664            max_dist = dist;
665            max_idx = i;
666        }
667    }
668
669    // If max distance is greater than epsilon, recursively simplify
670    if max_dist > epsilon {
671        let mut left = douglas_peucker(&points[..=max_idx], epsilon);
672        let right = douglas_peucker(&points[max_idx..], epsilon);
673
674        // Remove duplicate point
675        left.pop();
676        left.extend(right);
677        left
678    } else {
679        // Return just the endpoints
680        vec![start, end]
681    }
682}
683
684/// Calculate perpendicular distance from point to line.
685fn perpendicular_distance(point: (f64, f64), start: (f64, f64), end: (f64, f64)) -> f64 {
686    let dx = end.0 - start.0;
687    let dy = end.1 - start.1;
688
689    let mag = dx.hypot(dy);
690    if mag < 1e-10 {
691        return (point.0 - start.0).hypot(point.1 - start.1);
692    }
693
694    ((dy * point.0 - dx * point.1 + end.0 * start.1 - end.1 * start.0) / mag).abs()
695}
696
697/// Visvalingam-Whyatt line simplification algorithm.
698fn visvalingam_whyatt(points: &[(f64, f64)], threshold: f64) -> Vec<(f64, f64)> {
699    if points.len() < 3 {
700        return points.to_vec();
701    }
702
703    let mut result: Vec<(f64, f64)> = points.to_vec();
704
705    while result.len() > 2 {
706        // Find triangle with minimum area
707        let mut min_area = f64::INFINITY;
708        let mut min_idx = 1;
709
710        for i in 1..result.len() - 1 {
711            let area = triangle_area(result[i - 1], result[i], result[i + 1]);
712            if area < min_area {
713                min_area = area;
714                min_idx = i;
715            }
716        }
717
718        if min_area >= threshold {
719            break;
720        }
721
722        result.remove(min_idx);
723    }
724
725    result
726}
727
728/// Calculate triangle area using cross product.
729fn triangle_area(p1: (f64, f64), p2: (f64, f64), p3: (f64, f64)) -> f64 {
730    ((p2.0 - p1.0) * (p3.1 - p1.1) - (p3.0 - p1.0) * (p2.1 - p1.1)).abs() / 2.0
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::direct::{CellBuffer, DirectTerminalCanvas};
737
738    #[test]
739    fn test_line_chart_creation() {
740        let chart = LineChart::new().add_series("test", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED);
741        assert_eq!(chart.series.len(), 1);
742        assert_eq!(chart.series[0].name, "test");
743    }
744
745    #[test]
746    fn test_douglas_peucker() {
747        let points = vec![(0.0, 0.0), (1.0, 0.1), (2.0, 0.0), (3.0, 0.0)];
748        let simplified = douglas_peucker(&points, 0.5);
749        assert!(simplified.len() <= points.len());
750    }
751
752    #[test]
753    fn test_douglas_peucker_few_points() {
754        let points = vec![(0.0, 0.0), (1.0, 1.0)];
755        let simplified = douglas_peucker(&points, 0.5);
756        assert_eq!(simplified.len(), 2);
757    }
758
759    #[test]
760    fn test_visvalingam_whyatt() {
761        let points = vec![(0.0, 0.0), (1.0, 0.1), (2.0, 0.0), (3.0, 0.0)];
762        let simplified = visvalingam_whyatt(&points, 0.5);
763        assert!(simplified.len() <= points.len());
764    }
765
766    #[test]
767    fn test_visvalingam_whyatt_few_points() {
768        let points = vec![(0.0, 0.0), (1.0, 1.0)];
769        let simplified = visvalingam_whyatt(&points, 0.5);
770        assert_eq!(simplified.len(), 2);
771    }
772
773    #[test]
774    fn test_empty_chart() {
775        let chart = LineChart::new();
776        let (x_min, x_max) = chart.x_range();
777        assert_eq!(x_min, 0.0);
778        assert_eq!(x_max, 1.0);
779    }
780
781    #[test]
782    fn test_multi_series() {
783        let chart = LineChart::new()
784            .add_series("a", vec![(0.0, 0.0)], Color::RED)
785            .add_series("b", vec![(1.0, 1.0)], Color::BLUE)
786            .add_series("c", vec![(2.0, 2.0)], Color::GREEN);
787        assert_eq!(chart.series.len(), 3);
788    }
789
790    #[test]
791    fn test_line_chart_assertions() {
792        let chart = LineChart::default();
793        assert!(!chart.assertions().is_empty());
794    }
795
796    #[test]
797    fn test_line_chart_verify() {
798        let mut chart = LineChart::default();
799        chart.bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
800        assert!(chart.verify().is_valid());
801    }
802
803    #[test]
804    fn test_line_chart_children() {
805        let chart = LineChart::default();
806        assert!(chart.children().is_empty());
807    }
808
809    #[test]
810    fn test_line_chart_layout() {
811        let mut chart = LineChart::new().add_series(
812            "test",
813            vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.5)],
814            Color::RED,
815        );
816        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
817        let result = chart.layout(bounds);
818        assert!(result.size.width > 0.0);
819        assert!(result.size.height > 0.0);
820    }
821
822    #[test]
823    fn test_line_chart_paint() {
824        let mut chart = LineChart::new().add_series(
825            "test",
826            vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.5)],
827            Color::RED,
828        );
829        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
830        chart.layout(bounds);
831
832        let mut buffer = CellBuffer::new(80, 24);
833        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
834        chart.paint(&mut canvas);
835        // Just verify it doesn't panic
836    }
837
838    #[test]
839    fn test_line_chart_with_legend_positions() {
840        for pos in [
841            LegendPosition::TopRight,
842            LegendPosition::TopLeft,
843            LegendPosition::BottomRight,
844            LegendPosition::BottomLeft,
845            LegendPosition::None,
846        ] {
847            let mut chart = LineChart::new()
848                .add_series("s1", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED)
849                .with_legend(pos);
850            let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
851            chart.layout(bounds);
852            let mut buffer = CellBuffer::new(80, 24);
853            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
854            chart.paint(&mut canvas);
855        }
856    }
857
858    #[test]
859    fn test_line_chart_with_axis_config() {
860        let mut chart = LineChart::new()
861            .add_series("test", vec![(0.0, 0.0), (10.0, 100.0)], Color::RED)
862            .with_x_axis(Axis {
863                label: Some("X Label".to_string()),
864                min: Some(0.0),
865                max: Some(10.0),
866                ticks: 5,
867                grid: true,
868            })
869            .with_y_axis(Axis {
870                label: Some("Y Label".to_string()),
871                min: Some(0.0),
872                max: Some(100.0),
873                ticks: 10,
874                grid: true,
875            });
876        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
877        chart.layout(bounds);
878        let mut buffer = CellBuffer::new(80, 24);
879        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
880        chart.paint(&mut canvas);
881    }
882
883    #[test]
884    fn test_line_chart_with_simplification() {
885        let data: Vec<(f64, f64)> = (0..100)
886            .map(|i| (i as f64, (i as f64 * 0.1).sin()))
887            .collect();
888
889        // Test Douglas-Peucker
890        let mut chart = LineChart::new()
891            .add_series("dp", data.clone(), Color::RED)
892            .with_simplification(Simplification::DouglasPeucker { epsilon: 0.1 });
893        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
894        chart.layout(bounds);
895        let mut buffer = CellBuffer::new(80, 24);
896        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
897        chart.paint(&mut canvas);
898
899        // Test Visvalingam-Whyatt
900        let mut chart = LineChart::new()
901            .add_series("vw", data, Color::BLUE)
902            .with_simplification(Simplification::VisvalingamWhyatt { threshold: 0.1 });
903        chart.layout(bounds);
904        chart.paint(&mut canvas);
905    }
906
907    #[test]
908    fn test_line_chart_line_styles() {
909        for style in [
910            LineStyle::Solid,
911            LineStyle::Dashed,
912            LineStyle::Dotted,
913            LineStyle::Markers,
914        ] {
915            let mut chart = LineChart::new().add_series_styled(
916                "test",
917                vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.5)],
918                Color::RED,
919                style,
920            );
921            let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
922            chart.layout(bounds);
923            let mut buffer = CellBuffer::new(80, 24);
924            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
925            chart.paint(&mut canvas);
926        }
927    }
928
929    #[test]
930    fn test_line_chart_y_range() {
931        let chart = LineChart::new().add_series(
932            "test",
933            vec![(0.0, -5.0), (1.0, 10.0), (2.0, 3.0)],
934            Color::RED,
935        );
936        let (y_min, y_max) = chart.y_range();
937        assert!(y_min <= -5.0);
938        assert!(y_max >= 10.0);
939    }
940
941    #[test]
942    fn test_line_chart_x_range_with_data() {
943        let chart = LineChart::new().add_series("test", vec![(5.0, 0.0), (15.0, 1.0)], Color::RED);
944        let (x_min, x_max) = chart.x_range();
945        assert!(x_min <= 5.0);
946        assert!(x_max >= 15.0);
947    }
948
949    #[test]
950    fn test_triangle_area() {
951        let area = triangle_area((0.0, 0.0), (1.0, 0.0), (0.5, 1.0));
952        assert!((area - 0.5).abs() < 0.001);
953    }
954
955    #[test]
956    fn test_perpendicular_distance() {
957        // Point on the line
958        let dist = perpendicular_distance((0.5, 0.5), (0.0, 0.0), (1.0, 1.0));
959        assert!(dist < 0.001);
960
961        // Point away from line
962        let dist = perpendicular_distance((0.0, 1.0), (0.0, 0.0), (1.0, 0.0));
963        assert!((dist - 1.0).abs() < 0.001);
964    }
965
966    #[test]
967    fn test_axis_default() {
968        let axis = Axis::default();
969        assert!(axis.label.is_none());
970        assert!(axis.min.is_none());
971        assert!(axis.max.is_none());
972        assert_eq!(axis.ticks, 5);
973        assert!(!axis.grid);
974    }
975
976    #[test]
977    fn test_simplification_default() {
978        let simp = Simplification::default();
979        assert!(matches!(simp, Simplification::None));
980    }
981
982    #[test]
983    fn test_line_style_default() {
984        let style = LineStyle::default();
985        assert!(matches!(style, LineStyle::Solid));
986    }
987
988    #[test]
989    fn test_legend_position_default() {
990        let pos = LegendPosition::default();
991        assert!(matches!(pos, LegendPosition::TopRight));
992    }
993
994    #[test]
995    fn test_line_chart_compact() {
996        let chart = LineChart::new()
997            .add_series("test", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED)
998            .compact();
999        assert_eq!(chart.margin_left, 0.0);
1000        assert_eq!(chart.margin_bottom, 0.0);
1001        assert_eq!(chart.y_axis.ticks, 0);
1002        assert_eq!(chart.x_axis.ticks, 0);
1003        assert!(matches!(chart.legend, LegendPosition::None));
1004    }
1005
1006    #[test]
1007    fn test_line_chart_with_margins() {
1008        let chart = LineChart::new()
1009            .add_series("test", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED)
1010            .with_margins(10.0, 5.0);
1011        assert_eq!(chart.margin_left, 10.0);
1012        assert_eq!(chart.margin_bottom, 5.0);
1013    }
1014
1015    #[test]
1016    fn test_line_chart_explicit_x_range() {
1017        let chart = LineChart::new()
1018            .add_series("test", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED)
1019            .with_x_axis(Axis {
1020                min: Some(0.0),
1021                max: Some(10.0),
1022                ..Default::default()
1023            });
1024        let (xmin, xmax) = chart.x_range();
1025        assert_eq!(xmin, 0.0);
1026        assert_eq!(xmax, 10.0);
1027    }
1028
1029    #[test]
1030    fn test_line_chart_explicit_y_range() {
1031        let chart = LineChart::new()
1032            .add_series("test", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED)
1033            .with_y_axis(Axis {
1034                min: Some(-10.0),
1035                max: Some(10.0),
1036                ..Default::default()
1037            });
1038        let (ymin, ymax) = chart.y_range();
1039        assert_eq!(ymin, -10.0);
1040        assert_eq!(ymax, 10.0);
1041    }
1042
1043    #[test]
1044    fn test_line_chart_nan_values() {
1045        let mut chart = LineChart::new().add_series(
1046            "test",
1047            vec![(0.0, 0.0), (f64::NAN, f64::NAN), (2.0, 2.0)],
1048            Color::RED,
1049        );
1050        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
1051        chart.layout(bounds);
1052        let mut buffer = CellBuffer::new(80, 24);
1053        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1054        // Should not panic with NaN values
1055        chart.paint(&mut canvas);
1056    }
1057
1058    #[test]
1059    fn test_line_chart_infinite_values() {
1060        let mut chart = LineChart::new().add_series(
1061            "test",
1062            vec![(0.0, 0.0), (f64::INFINITY, f64::NEG_INFINITY), (2.0, 2.0)],
1063            Color::RED,
1064        );
1065        let bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
1066        chart.layout(bounds);
1067        let mut buffer = CellBuffer::new(80, 24);
1068        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1069        // Should not panic with infinite values
1070        chart.paint(&mut canvas);
1071    }
1072
1073    #[test]
1074    fn test_line_chart_too_small() {
1075        let mut chart =
1076            LineChart::new().add_series("test", vec![(0.0, 0.0), (1.0, 1.0)], Color::RED);
1077        // Too small - should early return from paint
1078        let bounds = Rect::new(0.0, 0.0, 5.0, 2.0);
1079        chart.layout(bounds);
1080        let mut buffer = CellBuffer::new(5, 2);
1081        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1082        chart.paint(&mut canvas);
1083    }
1084
1085    #[test]
1086    fn test_line_chart_children_mut() {
1087        let mut chart = LineChart::default();
1088        assert!(chart.children_mut().is_empty());
1089    }
1090
1091    #[test]
1092    fn test_line_chart_to_html() {
1093        let chart = LineChart::default();
1094        assert!(chart.to_html().is_empty());
1095    }
1096
1097    #[test]
1098    fn test_line_chart_to_css() {
1099        let chart = LineChart::default();
1100        assert!(chart.to_css().is_empty());
1101    }
1102
1103    #[test]
1104    fn test_line_chart_verify_small_width() {
1105        let mut chart = LineChart::default();
1106        chart.bounds = Rect::new(0.0, 0.0, 5.0, 20.0); // Width < 10
1107        let verification = chart.verify();
1108        assert!(!verification.is_valid());
1109    }
1110
1111    #[test]
1112    fn test_line_chart_budget() {
1113        let chart = LineChart::default();
1114        let budget = chart.budget();
1115        // Just verify budget returns something
1116        let _ = budget;
1117    }
1118
1119    #[test]
1120    fn test_line_chart_measure() {
1121        let chart = LineChart::default();
1122        let size = chart.measure(Constraints {
1123            min_width: 0.0,
1124            min_height: 0.0,
1125            max_width: 100.0,
1126            max_height: 50.0,
1127        });
1128        assert_eq!(size.width, 80.0);
1129        assert_eq!(size.height, 20.0);
1130    }
1131
1132    #[test]
1133    fn test_line_chart_type_id() {
1134        let chart = LineChart::default();
1135        // Use Widget trait explicitly
1136        let tid = Widget::type_id(&chart);
1137        assert_eq!(tid, TypeId::of::<LineChart>());
1138    }
1139
1140    #[test]
1141    fn test_draw_line_horizontal() {
1142        let mut grid = vec![vec![false; 10]; 20];
1143        draw_line(&mut grid, 0, 5, 19, 5);
1144        // Check some points on the line
1145        assert!(grid[0][5]);
1146        assert!(grid[10][5]);
1147        assert!(grid[19][5]);
1148    }
1149
1150    #[test]
1151    fn test_draw_line_vertical() {
1152        let mut grid = vec![vec![false; 10]; 20];
1153        draw_line(&mut grid, 5, 0, 5, 9);
1154        assert!(grid[5][0]);
1155        assert!(grid[5][5]);
1156        assert!(grid[5][9]);
1157    }
1158
1159    #[test]
1160    fn test_draw_line_diagonal() {
1161        let mut grid = vec![vec![false; 10]; 10];
1162        draw_line(&mut grid, 0, 0, 9, 9);
1163        assert!(grid[0][0]);
1164        assert!(grid[9][9]);
1165    }
1166
1167    #[test]
1168    fn test_draw_line_reverse() {
1169        let mut grid = vec![vec![false; 10]; 10];
1170        draw_line(&mut grid, 9, 9, 0, 0);
1171        assert!(grid[0][0]);
1172        assert!(grid[9][9]);
1173    }
1174
1175    #[test]
1176    fn test_perpendicular_distance_coincident_points() {
1177        // When start and end are the same point
1178        let dist = perpendicular_distance((1.0, 1.0), (0.0, 0.0), (0.0, 0.0));
1179        assert!((dist - std::f64::consts::SQRT_2).abs() < 0.001);
1180    }
1181
1182    #[test]
1183    fn test_series_struct() {
1184        let series = Series {
1185            name: "test".to_string(),
1186            data: vec![(0.0, 0.0), (1.0, 1.0)],
1187            color: Color::RED,
1188            style: LineStyle::Dashed,
1189        };
1190        assert_eq!(series.name, "test");
1191        assert_eq!(series.data.len(), 2);
1192        assert!(matches!(series.style, LineStyle::Dashed));
1193    }
1194
1195    #[test]
1196    fn test_line_chart_single_point_x_range() {
1197        // When all points have same X value
1198        let chart = LineChart::new().add_series("test", vec![(5.0, 0.0), (5.0, 1.0)], Color::RED);
1199        let (xmin, xmax) = chart.x_range();
1200        // x_max == x_min, so should use default fallback
1201        assert_eq!(xmin, 5.0);
1202        assert_eq!(xmax, 5.0);
1203    }
1204
1205    #[test]
1206    fn test_line_chart_single_point_y_range() {
1207        // When all points have same Y value
1208        let chart = LineChart::new().add_series("test", vec![(0.0, 5.0), (1.0, 5.0)], Color::RED);
1209        let (ymin, ymax) = chart.y_range();
1210        // When y_min == y_max, padding is 0, so both are 5.0
1211        assert_eq!(ymin, 5.0);
1212        assert_eq!(ymax, 5.0);
1213    }
1214}