Skip to main content

plotlars_core/plots/
scatterpolar.rs

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