Skip to main content

plotlars_core/plots/
lineplot.rs

1use bon::bon;
2
3use polars::{
4    frame::DataFrame,
5    prelude::{col, IntoLazy},
6};
7
8use crate::{
9    components::{
10        Axis, FacetConfig, Legend, Line as LineStyle, Mode, Rgb, Shape, Text, DEFAULT_PLOTLY_COLORS,
11    },
12    ir::data::ColumnData,
13    ir::layout::LayoutIR,
14    ir::line::LineIR,
15    ir::marker::MarkerIR,
16    ir::trace::{LinePlotIR, TraceIR},
17};
18
19/// A structure representing a line plot.
20///
21/// The `LinePlot` struct facilitates the creation and customization of line plots with various options
22/// for data selection, layout configuration, and aesthetic adjustments. It supports the addition of multiple
23/// lines, customization of marker shapes, line styles, colors, opacity settings, and comprehensive layout
24/// customization including titles, axes, and legends.
25///
26/// # Backend Support
27///
28/// | Backend | Supported |
29/// |---------|-----------|
30/// | Plotly  | Yes       |
31/// | Plotters| Yes       |
32///
33/// # Arguments
34///
35/// * `data` - A reference to the `DataFrame` containing the data to be plotted.
36/// * `x` - A string slice specifying the column name to be used for the x-axis (independent variable).
37/// * `y` - A string slice specifying the column name to be used for the y-axis (dependent variable).
38/// * `additional_lines` - An optional vector of string slices specifying additional y-axis columns to be plotted as lines.
39/// * `facet` - An optional string slice specifying the column name to be used for faceting (creating multiple subplots).
40/// * `facet_config` - An optional reference to a `FacetConfig` struct for customizing facet behavior (grid dimensions, scales, gaps, etc.).
41/// * `size` - An optional `usize` specifying the size of the markers or the thickness of the lines.
42/// * `color` - An optional `Rgb` value specifying the color of the markers and lines. This is used when `additional_lines` is not specified.
43/// * `colors` - An optional vector of `Rgb` values specifying the colors for the markers and lines. This is used when `additional_lines` is specified to differentiate between multiple lines.
44/// * `shape` - An optional `Shape` specifying the shape of the markers. When set, markers are automatically displayed on the plot.
45/// * `shapes` - An optional vector of `Shape` values specifying multiple shapes for the markers when plotting multiple lines. When set, markers are automatically displayed on the plot.
46/// * `width` - An optional `f64` specifying the width of the plotted lines.
47/// * `line` - An optional `Line` specifying the type of the line (e.g., solid, dashed). This is used when `additional_lines` is not specified.
48/// * `lines` - An optional vector of `Line` enums specifying the types of lines (e.g., solid, dashed) for each plotted line. This is used when `additional_lines` is specified to differentiate between multiple lines.
49/// * `plot_title` - An optional `Text` struct specifying the title of the plot.
50/// * `x_title` - An optional `Text` struct specifying the title of the x-axis.
51/// * `y_title` - An optional `Text` struct specifying the title of the y-axis.
52/// * `y2_title` - An optional `Text` struct specifying the title of the secondary y-axis.
53/// * `legend_title` - An optional `Text` struct specifying the title of the legend.
54/// * `x_axis` - An optional reference to an `Axis` struct for customizing the x-axis.
55/// * `y_axis` - An optional reference to an `Axis` struct for customizing the y-axis.
56/// * `y2_axis` - An optional reference to an `Axis` struct for customizing the secondary y-axis.
57/// * `legend` - An optional reference to a `Legend` struct for customizing the legend of the plot (e.g., positioning, font, etc.).
58///
59/// # Example
60///
61/// ```rust
62/// use ndarray::Array;
63/// use plotlars::{Axis, Line, LinePlot, Plot, Rgb, Text, TickDirection};
64/// use polars::prelude::*;
65///
66///
67/// let x_values = Array::linspace(0.0, 2.0 * std::f64::consts::PI, 1000).to_vec();
68/// let sine_values = x_values.iter().map(|arg0: &f64| f64::sin(*arg0)).collect::<Vec<_>>();
69/// let cosine_values = x_values.iter().map(|arg0: &f64| f64::cos(*arg0)).collect::<Vec<_>>();
70///
71/// let dataset = df![
72///         "x" => &x_values,
73///         "sine" => &sine_values,
74///         "cosine" => &cosine_values,
75///     ]
76///     .unwrap();
77///
78/// LinePlot::builder()
79///     .data(&dataset)
80///     .x("x")
81///     .y("sine")
82///     .additional_lines(vec!["cosine"])
83///     .colors(vec![
84///         Rgb(255, 0, 0),
85///         Rgb(0, 255, 0),
86///     ])
87///     .lines(vec![
88///         Line::Solid,
89///         Line::Dot,
90///     ])
91///     .width(3.0)
92///     .plot_title(
93///         Text::from("Line Plot")
94///             .font("Arial")
95///             .size(18)
96///     )
97///     .legend_title(
98///         Text::from("series")
99///             .font("Arial")
100///             .size(15)
101///     )
102///     .x_axis(
103///        &Axis::new()
104///            .tick_direction(TickDirection::OutSide)
105///            .axis_position(0.5)
106///            .tick_values(vec![
107///                0.5 * std::f64::consts::PI,
108///                std::f64::consts::PI,
109///                1.5 * std::f64::consts::PI,
110///                2.0 * std::f64::consts::PI,
111///            ])
112///            .tick_labels(vec!["π/2", "π", "3π/2", "2π"])
113///     )
114///     .y_axis(
115///        &Axis::new()
116///            .tick_direction(TickDirection::OutSide)
117///            .tick_values(vec![-1.0, 0.0, 1.0])
118///            .tick_labels(vec!["-1", "0", "1"])
119///     )
120///     .build()
121///     .plot();
122/// ```
123///
124/// ![Example](https://imgur.com/PaXG300.png)
125#[derive(Clone)]
126#[allow(dead_code)]
127pub struct LinePlot {
128    traces: Vec<TraceIR>,
129    layout: LayoutIR,
130}
131
132#[bon]
133impl LinePlot {
134    #[builder(on(String, into), on(Text, into))]
135    pub fn new(
136        data: &DataFrame,
137        x: &str,
138        y: &str,
139        additional_lines: Option<Vec<&str>>,
140        facet: Option<&str>,
141        facet_config: Option<&FacetConfig>,
142        size: Option<usize>,
143        color: Option<Rgb>,
144        colors: Option<Vec<Rgb>>,
145        shape: Option<Shape>,
146        shapes: Option<Vec<Shape>>,
147        width: Option<f64>,
148        line: Option<LineStyle>,
149        lines: Option<Vec<LineStyle>>,
150        plot_title: Option<Text>,
151        x_title: Option<Text>,
152        y_title: Option<Text>,
153        y2_title: Option<Text>,
154        legend_title: Option<Text>,
155        x_axis: Option<&Axis>,
156        y_axis: Option<&Axis>,
157        y2_axis: Option<&Axis>,
158        legend: Option<&Legend>,
159    ) -> Self {
160        let grid = facet.map(|facet_column| {
161            let config = facet_config.cloned().unwrap_or_default();
162            let facet_categories =
163                crate::data::get_unique_groups(data, facet_column, config.sorter);
164            let n_facets = facet_categories.len();
165            let (ncols, nrows) =
166                crate::faceting::calculate_grid_dimensions(n_facets, config.cols, config.rows);
167            crate::ir::facet::GridSpec {
168                kind: crate::ir::facet::FacetKind::Axis,
169                rows: nrows,
170                cols: ncols,
171                h_gap: config.h_gap,
172                v_gap: config.v_gap,
173                scales: config.scales.clone(),
174                n_facets,
175                facet_categories,
176                title_style: config.title_style.clone(),
177                x_title: x_title.clone(),
178                y_title: y_title.clone(),
179                x_axis: x_axis.cloned(),
180                y_axis: y_axis.cloned(),
181                legend_title: legend_title.clone(),
182                legend: legend.cloned(),
183            }
184        });
185
186        let layout = LayoutIR {
187            title: plot_title.clone(),
188            x_title: if grid.is_some() {
189                None
190            } else {
191                x_title.clone()
192            },
193            y_title: if grid.is_some() {
194                None
195            } else {
196                y_title.clone()
197            },
198            y2_title: if grid.is_some() {
199                None
200            } else {
201                y2_title.clone()
202            },
203            z_title: None,
204            legend_title: if grid.is_some() {
205                None
206            } else {
207                legend_title.clone()
208            },
209            legend: if grid.is_some() {
210                None
211            } else {
212                legend.cloned()
213            },
214            dimensions: None,
215            bar_mode: None,
216            box_mode: None,
217            box_gap: None,
218            margin_bottom: None,
219            axes_2d: if grid.is_some() {
220                None
221            } else {
222                Some(crate::ir::layout::Axes2dIR {
223                    x_axis: x_axis.cloned(),
224                    y_axis: y_axis.cloned(),
225                    y2_axis: y2_axis.cloned(),
226                })
227            },
228            scene_3d: None,
229            polar: None,
230            mapbox: None,
231            grid,
232            annotations: vec![],
233        };
234
235        let traces = match facet {
236            Some(facet_column) => {
237                let config = facet_config.cloned().unwrap_or_default();
238                Self::create_ir_traces_faceted(
239                    data,
240                    x,
241                    y,
242                    additional_lines,
243                    facet_column,
244                    &config,
245                    size,
246                    color,
247                    colors,
248                    shape,
249                    shapes,
250                    width,
251                    line,
252                    lines,
253                )
254            }
255            None => Self::create_ir_traces(
256                data,
257                x,
258                y,
259                additional_lines,
260                size,
261                color,
262                colors,
263                shape,
264                shapes,
265                width,
266                line,
267                lines,
268            ),
269        };
270
271        Self { traces, layout }
272    }
273}
274
275#[bon]
276impl LinePlot {
277    #[builder(
278        start_fn = try_builder,
279        finish_fn = try_build,
280        builder_type = LinePlotTryBuilder,
281        on(String, into),
282        on(Text, into),
283    )]
284    pub fn try_new(
285        data: &DataFrame,
286        x: &str,
287        y: &str,
288        additional_lines: Option<Vec<&str>>,
289        facet: Option<&str>,
290        facet_config: Option<&FacetConfig>,
291        size: Option<usize>,
292        color: Option<Rgb>,
293        colors: Option<Vec<Rgb>>,
294        shape: Option<Shape>,
295        shapes: Option<Vec<Shape>>,
296        width: Option<f64>,
297        line: Option<LineStyle>,
298        lines: Option<Vec<LineStyle>>,
299        plot_title: Option<Text>,
300        x_title: Option<Text>,
301        y_title: Option<Text>,
302        y2_title: Option<Text>,
303        legend_title: Option<Text>,
304        x_axis: Option<&Axis>,
305        y_axis: Option<&Axis>,
306        y2_axis: Option<&Axis>,
307        legend: Option<&Legend>,
308    ) -> Result<Self, crate::io::PlotlarsError> {
309        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
310            Self::__orig_new(
311                data,
312                x,
313                y,
314                additional_lines,
315                facet,
316                facet_config,
317                size,
318                color,
319                colors,
320                shape,
321                shapes,
322                width,
323                line,
324                lines,
325                plot_title,
326                x_title,
327                y_title,
328                y2_title,
329                legend_title,
330                x_axis,
331                y_axis,
332                y2_axis,
333                legend,
334            )
335        }))
336        .map_err(|panic| {
337            let msg = panic
338                .downcast_ref::<String>()
339                .cloned()
340                .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
341                .unwrap_or_else(|| "unknown error".to_string());
342            crate::io::PlotlarsError::PlotBuild { message: msg }
343        })
344    }
345}
346
347impl LinePlot {
348    #[allow(clippy::too_many_arguments)]
349    fn create_ir_traces(
350        data: &DataFrame,
351        x_col: &str,
352        y_col: &str,
353        additional_lines: Option<Vec<&str>>,
354        size: Option<usize>,
355        color: Option<Rgb>,
356        colors: Option<Vec<Rgb>>,
357        shape: Option<Shape>,
358        shapes: Option<Vec<Shape>>,
359        width: Option<f64>,
360        style: Option<LineStyle>,
361        styles: Option<Vec<LineStyle>>,
362    ) -> Vec<TraceIR> {
363        let mut traces = Vec::new();
364
365        let mode = Self::resolve_mode(shape, shapes.as_ref());
366
367        let marker_ir = MarkerIR {
368            opacity: None,
369            size,
370            color: Self::resolve_color(0, color, colors.clone()),
371            shape: Self::resolve_shape(0, shape, shapes.clone()),
372        };
373
374        let line_ir = Self::resolve_line_ir(0, width, style, styles.clone());
375
376        traces.push(TraceIR::LinePlot(LinePlotIR {
377            x: ColumnData::Numeric(crate::data::get_numeric_column(data, x_col)),
378            y: ColumnData::Numeric(crate::data::get_numeric_column(data, y_col)),
379            name: Some(y_col.to_string()),
380            marker: Some(marker_ir),
381            line: Some(line_ir),
382            mode,
383            show_legend: None,
384            legend_group: None,
385            subplot_ref: None,
386        }));
387
388        if let Some(additional_lines) = additional_lines {
389            for (i, series) in additional_lines.into_iter().enumerate() {
390                let subset = data
391                    .clone()
392                    .lazy()
393                    .select([col(x_col), col(series)])
394                    .collect()
395                    .unwrap();
396
397                let marker_ir = MarkerIR {
398                    opacity: None,
399                    size,
400                    color: Self::resolve_color(i + 1, color, colors.clone()),
401                    shape: Self::resolve_shape(i + 1, shape, shapes.clone()),
402                };
403
404                let line_ir = Self::resolve_line_ir(i + 1, width, style, styles.clone());
405
406                traces.push(TraceIR::LinePlot(LinePlotIR {
407                    x: ColumnData::Numeric(crate::data::get_numeric_column(&subset, x_col)),
408                    y: ColumnData::Numeric(crate::data::get_numeric_column(&subset, series)),
409                    name: Some(series.to_string()),
410                    marker: Some(marker_ir),
411                    line: Some(line_ir),
412                    mode,
413                    show_legend: None,
414                    legend_group: None,
415                    subplot_ref: None,
416                }));
417            }
418        }
419
420        traces
421    }
422
423    #[allow(clippy::too_many_arguments)]
424    fn create_ir_traces_faceted(
425        data: &DataFrame,
426        x: &str,
427        y: &str,
428        additional_lines: Option<Vec<&str>>,
429        facet_column: &str,
430        config: &FacetConfig,
431        size: Option<usize>,
432        color: Option<Rgb>,
433        colors: Option<Vec<Rgb>>,
434        shape: Option<Shape>,
435        shapes: Option<Vec<Shape>>,
436        width: Option<f64>,
437        style: Option<LineStyle>,
438        styles: Option<Vec<LineStyle>>,
439    ) -> Vec<TraceIR> {
440        const MAX_FACETS: usize = 8;
441
442        let facet_categories = crate::data::get_unique_groups(data, facet_column, config.sorter);
443
444        if facet_categories.len() > MAX_FACETS {
445            panic!(
446                "Facet column '{}' has {} unique values, but plotly.rs supports maximum {} subplots",
447                facet_column,
448                facet_categories.len(),
449                MAX_FACETS
450            );
451        }
452
453        let all_y_cols = if let Some(ref add_lines) = additional_lines {
454            let mut cols = vec![y];
455            cols.extend(add_lines.iter().copied());
456            cols
457        } else {
458            vec![y]
459        };
460
461        if let Some(ref color_vec) = colors {
462            if additional_lines.is_none() {
463                let color_count = color_vec.len();
464                let facet_count = facet_categories.len();
465                if color_count != facet_count {
466                    panic!(
467                        "When using colors with facet (without additional_lines), colors.len() must equal number of facets. \
468                         Expected {} colors for {} facets, but got {} colors. \
469                         Each facet must be assigned exactly one color.",
470                        facet_count, facet_count, color_count
471                    );
472                }
473            } else {
474                let color_count = color_vec.len();
475                let line_count = all_y_cols.len();
476                if color_count < line_count {
477                    panic!(
478                        "When using colors with additional_lines, colors.len() must be >= number of lines. \
479                         Need at least {} colors for {} lines, but got {} colors",
480                        line_count, line_count, color_count
481                    );
482                }
483            }
484        }
485
486        let colors = if additional_lines.is_some() && colors.is_none() {
487            Some(DEFAULT_PLOTLY_COLORS.to_vec())
488        } else {
489            colors
490        };
491
492        let mut traces = Vec::new();
493
494        let mode_for_facet = Self::resolve_mode(shape, shapes.as_ref());
495
496        if config.highlight_facet {
497            for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
498                let subplot_ref = format!(
499                    "{}{}",
500                    crate::faceting::get_axis_reference(facet_idx, "x"),
501                    crate::faceting::get_axis_reference(facet_idx, "y")
502                );
503
504                for other_facet_value in facet_categories.iter() {
505                    if other_facet_value != facet_value {
506                        let other_data = crate::data::filter_data_by_group(
507                            data,
508                            facet_column,
509                            other_facet_value,
510                        );
511
512                        let grey_color = config.unhighlighted_color.unwrap_or(Rgb(200, 200, 200));
513
514                        for (line_idx, y_col) in all_y_cols.iter().enumerate() {
515                            let marker_ir = MarkerIR {
516                                opacity: None,
517                                size,
518                                color: Some(grey_color),
519                                shape: Self::resolve_shape(line_idx, shape, None),
520                            };
521
522                            let line_ir =
523                                Self::resolve_line_ir(line_idx, width, style, styles.clone());
524
525                            traces.push(TraceIR::LinePlot(LinePlotIR {
526                                x: ColumnData::Numeric(crate::data::get_numeric_column(
527                                    &other_data,
528                                    x,
529                                )),
530                                y: ColumnData::Numeric(crate::data::get_numeric_column(
531                                    &other_data,
532                                    y_col,
533                                )),
534                                name: None,
535                                marker: Some(marker_ir),
536                                line: Some(line_ir),
537                                mode: mode_for_facet,
538                                show_legend: Some(false),
539                                legend_group: None,
540                                subplot_ref: Some(subplot_ref.clone()),
541                            }));
542                        }
543                    }
544                }
545
546                let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
547
548                for (line_idx, y_col) in all_y_cols.iter().enumerate() {
549                    let color_index = if additional_lines.is_none() {
550                        facet_idx
551                    } else {
552                        line_idx
553                    };
554
555                    let marker_ir = MarkerIR {
556                        opacity: None,
557                        size,
558                        color: Self::resolve_color(color_index, color, colors.clone()),
559                        shape: Self::resolve_shape(color_index, shape, shapes.clone()),
560                    };
561
562                    let line_ir = Self::resolve_line_ir(line_idx, width, style, styles.clone());
563
564                    let show_legend = facet_idx == 0;
565                    let name = if show_legend {
566                        Some(y_col.to_string())
567                    } else {
568                        None
569                    };
570
571                    traces.push(TraceIR::LinePlot(LinePlotIR {
572                        x: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, x)),
573                        y: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, y_col)),
574                        name,
575                        marker: Some(marker_ir),
576                        line: Some(line_ir),
577                        mode: mode_for_facet,
578                        show_legend: Some(show_legend),
579                        legend_group: Some(y_col.to_string()),
580                        subplot_ref: Some(subplot_ref.clone()),
581                    }));
582                }
583            }
584        } else {
585            for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
586                let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
587
588                let subplot_ref = format!(
589                    "{}{}",
590                    crate::faceting::get_axis_reference(facet_idx, "x"),
591                    crate::faceting::get_axis_reference(facet_idx, "y")
592                );
593
594                for (line_idx, y_col) in all_y_cols.iter().enumerate() {
595                    let color_index = if additional_lines.is_none() {
596                        facet_idx
597                    } else {
598                        line_idx
599                    };
600
601                    let marker_ir = MarkerIR {
602                        opacity: None,
603                        size,
604                        color: Self::resolve_color(color_index, color, colors.clone()),
605                        shape: Self::resolve_shape(color_index, shape, shapes.clone()),
606                    };
607
608                    let line_ir = Self::resolve_line_ir(line_idx, width, style, styles.clone());
609
610                    let show_legend = facet_idx == 0;
611                    let name = if show_legend {
612                        Some(y_col.to_string())
613                    } else {
614                        None
615                    };
616
617                    traces.push(TraceIR::LinePlot(LinePlotIR {
618                        x: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, x)),
619                        y: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, y_col)),
620                        name,
621                        marker: Some(marker_ir),
622                        line: Some(line_ir),
623                        mode: mode_for_facet,
624                        show_legend: Some(show_legend),
625                        legend_group: Some(y_col.to_string()),
626                        subplot_ref: Some(subplot_ref.clone()),
627                    }));
628                }
629            }
630        }
631
632        traces
633    }
634
635    fn resolve_color(index: usize, color: Option<Rgb>, colors: Option<Vec<Rgb>>) -> Option<Rgb> {
636        if let Some(c) = color {
637            return Some(c);
638        }
639        if let Some(ref cs) = colors {
640            return cs.get(index).copied();
641        }
642        None
643    }
644
645    fn resolve_shape(
646        index: usize,
647        shape: Option<Shape>,
648        shapes: Option<Vec<Shape>>,
649    ) -> Option<Shape> {
650        if let Some(s) = shape {
651            return Some(s);
652        }
653        if let Some(ref ss) = shapes {
654            return ss.get(index).cloned();
655        }
656        None
657    }
658
659    fn resolve_line_ir(
660        index: usize,
661        width: Option<f64>,
662        style: Option<LineStyle>,
663        styles: Option<Vec<LineStyle>>,
664    ) -> LineIR {
665        let resolved_style = if style.is_some() {
666            style
667        } else {
668            styles.and_then(|ss| ss.get(index).cloned())
669        };
670
671        LineIR {
672            width,
673            style: resolved_style,
674            color: None,
675        }
676    }
677
678    fn resolve_mode(shape: Option<Shape>, shapes: Option<&Vec<Shape>>) -> Option<Mode> {
679        if shape.is_some() || shapes.is_some() {
680            Some(Mode::LinesMarkers)
681        } else {
682            Some(Mode::Lines)
683        }
684    }
685}
686
687impl crate::Plot for LinePlot {
688    fn ir_traces(&self) -> &[TraceIR] {
689        &self.traces
690    }
691
692    fn ir_layout(&self) -> &LayoutIR {
693        &self.layout
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::Plot;
701    use polars::prelude::*;
702
703    fn assert_rgb(actual: Option<Rgb>, r: u8, g: u8, b: u8) {
704        let c = actual.expect("expected Some(Rgb)");
705        assert_eq!((c.0, c.1, c.2), (r, g, b));
706    }
707
708    #[test]
709    fn test_basic_one_trace() {
710        let df = df!["x" => [1.0, 2.0, 3.0], "y" => [4.0, 5.0, 6.0]].unwrap();
711        let plot = LinePlot::builder().data(&df).x("x").y("y").build();
712        assert_eq!(plot.ir_traces().len(), 1);
713        assert!(matches!(plot.ir_traces()[0], TraceIR::LinePlot(_)));
714    }
715
716    #[test]
717    fn test_with_additional_lines() {
718        let df = df![
719            "x" => [1.0, 2.0, 3.0],
720            "y" => [4.0, 5.0, 6.0],
721            "y2" => [7.0, 8.0, 9.0]
722        ]
723        .unwrap();
724        let plot = LinePlot::builder()
725            .data(&df)
726            .x("x")
727            .y("y")
728            .additional_lines(vec!["y2"])
729            .build();
730        assert_eq!(plot.ir_traces().len(), 2);
731        assert!(matches!(plot.ir_traces()[1], TraceIR::LinePlot(_)));
732    }
733
734    #[test]
735    fn test_layout_titles() {
736        let df = df!["x" => [1.0, 2.0], "y" => [3.0, 4.0]].unwrap();
737        let plot = LinePlot::builder()
738            .data(&df)
739            .x("x")
740            .y("y")
741            .plot_title("My Title")
742            .x_title("X Axis")
743            .y_title("Y Axis")
744            .build();
745        let layout = plot.ir_layout();
746        assert!(layout.title.is_some());
747        assert!(layout.x_title.is_some());
748        assert!(layout.y_title.is_some());
749        assert!(layout.axes_2d.is_some());
750    }
751
752    #[test]
753    fn test_resolve_color_singular_priority() {
754        let result = LinePlot::resolve_color(0, Some(Rgb(255, 0, 0)), Some(vec![Rgb(0, 0, 255)]));
755        assert_rgb(result, 255, 0, 0);
756    }
757
758    #[test]
759    fn test_resolve_color_both_none() {
760        let result = LinePlot::resolve_color(0, None, None);
761        assert!(result.is_none());
762    }
763
764    #[test]
765    fn test_faceted_trace_count() {
766        let df = df![
767            "x" => [1.0, 2.0, 3.0, 4.0],
768            "y" => [4.0, 5.0, 6.0, 7.0],
769            "facet_col" => ["a", "a", "b", "b"]
770        ]
771        .unwrap();
772        let plot = LinePlot::builder()
773            .data(&df)
774            .x("x")
775            .y("y")
776            .facet("facet_col")
777            .build();
778        // 2 facets, 1 line each = 2 traces
779        assert_eq!(plot.ir_traces().len(), 2);
780    }
781}