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#[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}