use bon::bon;
use polars::frame::DataFrame;
use crate::{
components::{
FacetConfig, Fill, Legend, Line as LineStyle, Mode, Rgb, Shape, Text, DEFAULT_PLOTLY_COLORS,
},
ir::data::ColumnData,
ir::layout::LayoutIR,
ir::line::LineIR,
ir::marker::MarkerIR,
ir::trace::{ScatterPolarIR, TraceIR},
};
#[derive(Clone)]
#[allow(dead_code)]
pub struct ScatterPolar {
traces: Vec<TraceIR>,
layout: LayoutIR,
}
#[bon]
impl ScatterPolar {
#[builder(on(String, into), on(Text, into))]
pub fn new(
data: &DataFrame,
theta: &str,
r: &str,
group: Option<&str>,
sort_groups_by: Option<fn(&str, &str) -> std::cmp::Ordering>,
facet: Option<&str>,
facet_config: Option<&FacetConfig>,
mode: Option<Mode>,
opacity: Option<f64>,
fill: Option<Fill>,
size: Option<usize>,
color: Option<Rgb>,
colors: Option<Vec<Rgb>>,
shape: Option<Shape>,
shapes: Option<Vec<Shape>>,
width: Option<f64>,
line: Option<LineStyle>,
lines: Option<Vec<LineStyle>>,
plot_title: Option<Text>,
legend_title: Option<Text>,
legend: Option<&Legend>,
) -> Self {
let traces = match facet {
Some(facet_column) => {
let config = facet_config.cloned().unwrap_or_default();
Self::create_ir_traces_faceted(
data,
theta,
r,
group,
sort_groups_by,
facet_column,
&config,
mode,
opacity,
fill,
size,
color,
colors,
shape,
shapes,
width,
line,
lines,
)
}
None => Self::create_ir_traces(
data,
theta,
r,
group,
sort_groups_by,
mode,
opacity,
fill,
size,
color,
colors,
shape,
shapes,
width,
line,
lines,
),
};
let grid = facet.map(|facet_column| {
let config = facet_config.cloned().unwrap_or_default();
let facet_categories =
crate::data::get_unique_groups(data, facet_column, config.sorter);
let n_facets = facet_categories.len();
let (ncols, nrows) =
crate::faceting::calculate_grid_dimensions(n_facets, config.cols, config.rows);
crate::ir::facet::GridSpec {
kind: crate::ir::facet::FacetKind::Polar,
rows: nrows,
cols: ncols,
h_gap: config.h_gap,
v_gap: config.v_gap,
scales: config.scales.clone(),
n_facets,
facet_categories,
title_style: config.title_style.clone(),
x_title: None,
y_title: None,
x_axis: None,
y_axis: None,
legend_title: legend_title.clone(),
legend: legend.cloned(),
}
});
let layout = LayoutIR {
title: plot_title,
x_title: None,
y_title: None,
y2_title: None,
z_title: None,
legend_title: if grid.is_some() { None } else { legend_title },
legend: if grid.is_some() {
None
} else {
legend.cloned()
},
dimensions: None,
bar_mode: None,
box_mode: None,
box_gap: None,
margin_bottom: None,
axes_2d: None,
scene_3d: None,
polar: None,
mapbox: None,
grid,
annotations: vec![],
};
Self { traces, layout }
}
}
#[bon]
impl ScatterPolar {
#[builder(
start_fn = try_builder,
finish_fn = try_build,
builder_type = ScatterPolarTryBuilder,
on(String, into),
on(Text, into),
)]
pub fn try_new(
data: &DataFrame,
theta: &str,
r: &str,
group: Option<&str>,
sort_groups_by: Option<fn(&str, &str) -> std::cmp::Ordering>,
facet: Option<&str>,
facet_config: Option<&FacetConfig>,
mode: Option<Mode>,
opacity: Option<f64>,
fill: Option<Fill>,
size: Option<usize>,
color: Option<Rgb>,
colors: Option<Vec<Rgb>>,
shape: Option<Shape>,
shapes: Option<Vec<Shape>>,
width: Option<f64>,
line: Option<LineStyle>,
lines: Option<Vec<LineStyle>>,
plot_title: Option<Text>,
legend_title: Option<Text>,
legend: Option<&Legend>,
) -> Result<Self, crate::io::PlotlarsError> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Self::__orig_new(
data,
theta,
r,
group,
sort_groups_by,
facet,
facet_config,
mode,
opacity,
fill,
size,
color,
colors,
shape,
shapes,
width,
line,
lines,
plot_title,
legend_title,
legend,
)
}))
.map_err(|panic| {
let msg = panic
.downcast_ref::<String>()
.cloned()
.or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown error".to_string());
crate::io::PlotlarsError::PlotBuild { message: msg }
})
}
}
impl ScatterPolar {
fn get_polar_subplot_reference(index: usize) -> String {
match index {
0 => "polar".to_string(),
1 => "polar2".to_string(),
2 => "polar3".to_string(),
3 => "polar4".to_string(),
4 => "polar5".to_string(),
5 => "polar6".to_string(),
6 => "polar7".to_string(),
7 => "polar8".to_string(),
_ => "polar".to_string(),
}
}
#[allow(clippy::too_many_arguments)]
fn create_ir_traces(
data: &DataFrame,
theta: &str,
r: &str,
group: Option<&str>,
sort_groups_by: Option<fn(&str, &str) -> std::cmp::Ordering>,
mode: Option<Mode>,
opacity: Option<f64>,
fill: Option<Fill>,
size: Option<usize>,
color: Option<Rgb>,
colors: Option<Vec<Rgb>>,
shape: Option<Shape>,
shapes: Option<Vec<Shape>>,
width: Option<f64>,
line: Option<LineStyle>,
lines: Option<Vec<LineStyle>>,
) -> Vec<TraceIR> {
let mut traces = Vec::new();
match group {
Some(group_col) => {
let groups = crate::data::get_unique_groups(data, group_col, sort_groups_by);
for (i, group_name) in groups.iter().enumerate() {
let subset = crate::data::filter_data_by_group(data, group_col, group_name);
let marker_ir = MarkerIR {
opacity,
size,
color: Self::resolve_color(i, color, colors.clone()),
shape: Self::resolve_shape(i, shape, shapes.clone()),
};
let line_ir = LineIR {
width,
color: Self::resolve_color(i, color, colors.clone()),
style: Self::resolve_line_style(i, line, lines.clone()),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(&subset, theta)),
r: ColumnData::Numeric(crate::data::get_numeric_column(&subset, r)),
name: Some(group_name.to_string()),
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: None,
legend_group: None,
subplot_ref: None,
}));
}
}
None => {
let marker_ir = MarkerIR {
opacity,
size,
color: Self::resolve_color(0, color, colors),
shape: Self::resolve_shape(0, shape, shapes),
};
let line_ir = LineIR {
width,
color,
style: Self::resolve_line_style(0, line, lines),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(data, theta)),
r: ColumnData::Numeric(crate::data::get_numeric_column(data, r)),
name: None,
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: None,
legend_group: None,
subplot_ref: None,
}));
}
}
traces
}
#[allow(clippy::too_many_arguments)]
fn create_ir_traces_faceted(
data: &DataFrame,
theta: &str,
r: &str,
group: Option<&str>,
sort_groups_by: Option<fn(&str, &str) -> std::cmp::Ordering>,
facet_column: &str,
config: &FacetConfig,
mode: Option<Mode>,
opacity: Option<f64>,
fill: Option<Fill>,
size: Option<usize>,
color: Option<Rgb>,
colors: Option<Vec<Rgb>>,
shape: Option<Shape>,
shapes: Option<Vec<Shape>>,
width: Option<f64>,
line: Option<LineStyle>,
lines: Option<Vec<LineStyle>>,
) -> Vec<TraceIR> {
const MAX_FACETS: usize = 8;
let facet_categories = crate::data::get_unique_groups(data, facet_column, config.sorter);
if facet_categories.len() > MAX_FACETS {
panic!(
"Facet column '{}' has {} unique values, but plotly.rs supports maximum {} polar subplots",
facet_column,
facet_categories.len(),
MAX_FACETS
);
}
if let Some(ref color_vec) = colors {
if group.is_none() {
let color_count = color_vec.len();
let facet_count = facet_categories.len();
if color_count != facet_count {
panic!(
"When using colors with facet (without group), colors.len() must equal number of facets. \
Expected {} colors for {} facets, but got {} colors. \
Each facet must be assigned exactly one color.",
facet_count, facet_count, color_count
);
}
} else if let Some(group_col) = group {
let groups = crate::data::get_unique_groups(data, group_col, sort_groups_by);
let color_count = color_vec.len();
let group_count = groups.len();
if color_count < group_count {
panic!(
"When using colors with group, colors.len() must be >= number of groups. \
Need at least {} colors for {} groups, but got {} colors",
group_count, group_count, color_count
);
}
}
}
let global_group_indices: std::collections::HashMap<String, usize> =
if let Some(group_col) = group {
let global_groups = crate::data::get_unique_groups(data, group_col, sort_groups_by);
global_groups
.into_iter()
.enumerate()
.map(|(idx, group_name)| (group_name, idx))
.collect()
} else {
std::collections::HashMap::new()
};
let colors = if group.is_some() && colors.is_none() {
Some(DEFAULT_PLOTLY_COLORS.to_vec())
} else {
colors
};
let mut traces = Vec::new();
if config.highlight_facet {
for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
let subplot = Self::get_polar_subplot_reference(facet_idx);
for other_facet_value in facet_categories.iter() {
if other_facet_value != facet_value {
let other_data = crate::data::filter_data_by_group(
data,
facet_column,
other_facet_value,
);
let grey_color = config.unhighlighted_color.unwrap_or(Rgb(200, 200, 200));
let marker_ir = MarkerIR {
opacity,
size,
color: Some(grey_color),
shape: Self::resolve_shape(0, shape, None),
};
let line_ir = LineIR {
width,
color: Some(grey_color),
style: Self::resolve_line_style(0, line, None),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(
&other_data,
theta,
)),
r: ColumnData::Numeric(crate::data::get_numeric_column(&other_data, r)),
name: None,
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: Some(false),
legend_group: None,
subplot_ref: Some(subplot.clone()),
}));
}
}
let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
match group {
Some(group_col) => {
let groups =
crate::data::get_unique_groups(&facet_data, group_col, sort_groups_by);
for group_val in groups.iter() {
let group_data = crate::data::filter_data_by_group(
&facet_data,
group_col,
group_val,
);
let global_idx =
global_group_indices.get(group_val).copied().unwrap_or(0);
let marker_ir = MarkerIR {
opacity,
size,
color: Self::resolve_color(global_idx, color, colors.clone()),
shape: Self::resolve_shape(global_idx, shape, shapes.clone()),
};
let line_ir = LineIR {
width,
color: Self::resolve_color(global_idx, color, colors.clone()),
style: Self::resolve_line_style(global_idx, line, lines.clone()),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(
&group_data,
theta,
)),
r: ColumnData::Numeric(crate::data::get_numeric_column(
&group_data,
r,
)),
name: Some(group_val.to_string()),
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: Some(facet_idx == 0),
legend_group: Some(group_val.to_string()),
subplot_ref: Some(subplot.clone()),
}));
}
}
None => {
let marker_ir = MarkerIR {
opacity,
size,
color: Self::resolve_color(facet_idx, color, colors.clone()),
shape: Self::resolve_shape(facet_idx, shape, shapes.clone()),
};
let line_ir = LineIR {
width,
color: Self::resolve_color(facet_idx, color, colors.clone()),
style: Self::resolve_line_style(facet_idx, line, lines.clone()),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(
&facet_data,
theta,
)),
r: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, r)),
name: None,
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: Some(false),
legend_group: None,
subplot_ref: Some(subplot.clone()),
}));
}
}
}
} else {
for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
let subplot = Self::get_polar_subplot_reference(facet_idx);
match group {
Some(group_col) => {
let groups =
crate::data::get_unique_groups(&facet_data, group_col, sort_groups_by);
for group_val in groups.iter() {
let group_data = crate::data::filter_data_by_group(
&facet_data,
group_col,
group_val,
);
let global_idx =
global_group_indices.get(group_val).copied().unwrap_or(0);
let marker_ir = MarkerIR {
opacity,
size,
color: Self::resolve_color(global_idx, color, colors.clone()),
shape: Self::resolve_shape(global_idx, shape, shapes.clone()),
};
let line_ir = LineIR {
width,
color: Self::resolve_color(global_idx, color, colors.clone()),
style: Self::resolve_line_style(global_idx, line, lines.clone()),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(
&group_data,
theta,
)),
r: ColumnData::Numeric(crate::data::get_numeric_column(
&group_data,
r,
)),
name: Some(group_val.to_string()),
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: Some(facet_idx == 0),
legend_group: Some(group_val.to_string()),
subplot_ref: Some(subplot.clone()),
}));
}
}
None => {
let marker_ir = MarkerIR {
opacity,
size,
color: Self::resolve_color(facet_idx, color, colors.clone()),
shape: Self::resolve_shape(facet_idx, shape, shapes.clone()),
};
let line_ir = LineIR {
width,
color: Self::resolve_color(facet_idx, color, colors.clone()),
style: Self::resolve_line_style(facet_idx, line, lines.clone()),
};
traces.push(TraceIR::ScatterPolar(ScatterPolarIR {
theta: ColumnData::Numeric(crate::data::get_numeric_column(
&facet_data,
theta,
)),
r: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, r)),
name: None,
mode,
marker: Some(marker_ir),
line: Some(line_ir),
fill,
show_legend: Some(false),
legend_group: None,
subplot_ref: Some(subplot.clone()),
}));
}
}
}
}
traces
}
fn resolve_color(index: usize, color: Option<Rgb>, colors: Option<Vec<Rgb>>) -> Option<Rgb> {
if let Some(c) = color {
return Some(c);
}
if let Some(ref cs) = colors {
return cs.get(index).copied();
}
None
}
fn resolve_shape(
index: usize,
shape: Option<Shape>,
shapes: Option<Vec<Shape>>,
) -> Option<Shape> {
if let Some(s) = shape {
return Some(s);
}
if let Some(ref ss) = shapes {
return ss.get(index).cloned();
}
None
}
fn resolve_line_style(
index: usize,
style: Option<LineStyle>,
styles: Option<Vec<LineStyle>>,
) -> Option<LineStyle> {
if let Some(s) = style {
return Some(s);
}
if let Some(ref ss) = styles {
return ss.get(index).cloned();
}
None
}
}
impl crate::Plot for ScatterPolar {
fn ir_traces(&self) -> &[TraceIR] {
&self.traces
}
fn ir_layout(&self) -> &LayoutIR {
&self.layout
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Plot;
use polars::prelude::*;
fn assert_rgb(actual: Option<Rgb>, r: u8, g: u8, b: u8) {
let c = actual.expect("expected Some(Rgb)");
assert_eq!((c.0, c.1, c.2), (r, g, b));
}
#[test]
fn test_basic_one_trace() {
let df = df![
"theta" => [0.0, 90.0, 180.0],
"r" => [1.0, 2.0, 3.0]
]
.unwrap();
let plot = ScatterPolar::builder()
.data(&df)
.theta("theta")
.r("r")
.build();
assert_eq!(plot.ir_traces().len(), 1);
assert!(matches!(plot.ir_traces()[0], TraceIR::ScatterPolar(_)));
}
#[test]
fn test_with_group() {
let df = df![
"theta" => [0.0, 90.0, 180.0, 270.0],
"r" => [1.0, 2.0, 3.0, 4.0],
"g" => ["a", "b", "a", "b"]
]
.unwrap();
let plot = ScatterPolar::builder()
.data(&df)
.theta("theta")
.r("r")
.group("g")
.build();
assert_eq!(plot.ir_traces().len(), 2);
}
#[test]
fn test_resolve_color_singular_priority() {
let result =
ScatterPolar::resolve_color(0, Some(Rgb(255, 0, 0)), Some(vec![Rgb(0, 0, 255)]));
assert_rgb(result, 255, 0, 0);
}
#[test]
fn test_resolve_shape_both_none() {
let result = ScatterPolar::resolve_shape(0, None, None);
assert!(result.is_none());
}
}