plotlars_core/components/facet.rs
1use crate::components::{Rgb, Text};
2use std::cmp::Ordering;
3
4/// Controls axis scaling behavior across facets in a faceted plot.
5///
6/// This enum determines whether facets share the same axis ranges or have independent scales.
7/// The behavior is similar to ggplot2's `scales` parameter in `facet_wrap()`.
8#[derive(Clone, Default)]
9pub enum FacetScales {
10 #[default]
11 Fixed,
12 Free,
13 FreeX,
14 FreeY,
15}
16
17/// A structure representing facet configuration for creating small multiples.
18///
19/// The `FacetConfig` struct allows customization of faceted plots including grid layout,
20/// scale behavior, spacing, title styling, custom ordering, and highlighting options.
21/// Faceting splits data by a categorical variable to create multiple subplots arranged
22/// in a grid, making it easy to compare patterns across categories.
23///
24/// # Example
25///
26/// ```rust
27/// use plotlars::{SurfacePlot, FacetConfig, Plot, Palette, Text};
28/// use polars::prelude::*;
29/// use ndarray::Array;
30///
31/// let n: usize = 50;
32/// let (x_base, _): (Vec<f64>, Option<usize>) =
33/// Array::linspace(-5., 5., n).into_raw_vec_and_offset();
34/// let (y_base, _): (Vec<f64>, Option<usize>) =
35/// Array::linspace(-5., 5., n).into_raw_vec_and_offset();
36///
37/// let mut x_all = Vec::new();
38/// let mut y_all = Vec::new();
39/// let mut z_all = Vec::new();
40/// let mut category_all = Vec::new();
41///
42/// type SurfaceFunction = Box<dyn Fn(f64, f64) -> f64>;
43/// let functions: Vec<(&str, SurfaceFunction)> = vec![
44/// (
45/// "Sine Wave",
46/// Box::new(|xi: f64, yj: f64| (xi * xi + yj * yj).sqrt().sin()),
47/// ),
48/// ("Saddle", Box::new(|xi: f64, yj: f64| xi * xi - yj * yj)),
49/// (
50/// "Gaussian",
51/// Box::new(|xi: f64, yj: f64| (-0.5 * (xi * xi + yj * yj)).exp()),
52/// ),
53/// ];
54///
55/// for (name, func) in &functions {
56/// for &xi in x_base.iter() {
57/// for &yj in y_base.iter() {
58/// x_all.push(xi);
59/// y_all.push(yj);
60/// z_all.push(func(xi, yj));
61/// category_all.push(*name);
62/// }
63/// }
64/// }
65///
66/// let dataset = df![
67/// "x" => &x_all,
68/// "y" => &y_all,
69/// "z" => &z_all,
70/// "function" => &category_all,
71/// ]
72/// .unwrap();
73///
74/// SurfacePlot::builder()
75/// .data(&dataset)
76/// .x("x")
77/// .y("y")
78/// .z("z")
79/// .facet("function")
80/// .facet_config(&FacetConfig::new().cols(3).rows(1).h_gap(0.08).v_gap(0.12))
81/// .plot_title(
82/// Text::from("3D Mathematical Functions")
83/// .font("Arial")
84/// .size(20),
85/// )
86/// .color_scale(Palette::Viridis)
87/// .opacity(0.9)
88/// .build()
89/// .plot();
90/// ```
91///
92/// 
93#[derive(Clone, Default)]
94pub struct FacetConfig {
95 pub rows: Option<usize>,
96 pub cols: Option<usize>,
97 pub scales: FacetScales,
98 pub h_gap: Option<f64>,
99 pub v_gap: Option<f64>,
100 pub title_style: Option<Text>,
101 pub sorter: Option<fn(&str, &str) -> Ordering>,
102 pub highlight_facet: bool,
103 pub unhighlighted_color: Option<Rgb>,
104}
105
106impl FacetConfig {
107 /// Creates a new `FacetConfig` instance with default values.
108 ///
109 /// By default, the grid dimensions are automatically calculated, scales are fixed
110 /// across all facets, and highlighting is disabled.
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 /// Sets the number of rows in the facet grid.
116 ///
117 /// When specified, the grid will have exactly this many rows, and the number
118 /// of columns will be calculated automatically based on the number of facets. If not
119 /// specified, both dimensions are calculated automatically.
120 ///
121 /// # Argument
122 ///
123 /// * `rows` - A `usize` value specifying the number of rows (must be greater than 0).
124 ///
125 /// # Panics
126 ///
127 /// Panics if `rows` is 0.
128 pub fn rows(mut self, rows: usize) -> Self {
129 if rows == 0 {
130 panic!("rows must be greater than 0");
131 }
132 self.rows = Some(rows);
133 self
134 }
135
136 /// Sets the number of columns in the facet grid.
137 ///
138 /// When specified, the grid will have exactly this many columns, and the number
139 /// of rows will be calculated automatically based on the number of facets. If not
140 /// specified, both dimensions are calculated automatically.
141 ///
142 /// # Argument
143 ///
144 /// * `cols` - A `usize` value specifying the number of columns (must be greater than 0).
145 ///
146 /// # Panics
147 ///
148 /// Panics if `cols` is 0.
149 pub fn cols(mut self, cols: usize) -> Self {
150 if cols == 0 {
151 panic!("cols must be greater than 0");
152 }
153 self.cols = Some(cols);
154 self
155 }
156
157 /// Sets the axis scale behavior across facets.
158 ///
159 /// Controls whether facets share the same axis ranges (`Fixed`) or have independent
160 /// scales (`Free`, `FreeX`, or `FreeY`). Fixed scales make it easier to compare values
161 /// across facets, while free scales allow each facet to use its optimal range.
162 ///
163 /// # Argument
164 ///
165 /// * `scales` - A `FacetScales` enum value specifying the scale behavior.
166 pub fn scales(mut self, scales: FacetScales) -> Self {
167 self.scales = scales;
168 self
169 }
170
171 /// Sets the horizontal spacing between columns.
172 ///
173 /// The gap is specified as a proportion of the plot width, with typical values
174 /// ranging from 0.0 (no gap) to 0.2 (20% gap). If not specified, plotly's default
175 /// spacing is used.
176 ///
177 /// # Argument
178 ///
179 /// * `gap` - A `f64` value from 0.0 to 1.0 representing the relative gap size.
180 ///
181 /// # Panics
182 ///
183 /// Panics if `gap` is negative, NaN, or infinite.
184 pub fn h_gap(mut self, gap: f64) -> Self {
185 if !gap.is_finite() || gap < 0.0 {
186 panic!("h_gap must be a finite non-negative number");
187 }
188 self.h_gap = Some(gap);
189 self
190 }
191
192 /// Sets the vertical spacing between rows.
193 ///
194 /// The gap is specified as a proportion of the plot height, with typical values
195 /// ranging from 0.0 (no gap) to 0.2 (20% gap). If not specified, plotly's default
196 /// spacing is used.
197 ///
198 /// # Argument
199 ///
200 /// * `gap` - A `f64` value from 0.0 to 1.0 representing the relative gap size.
201 ///
202 /// # Panics
203 ///
204 /// Panics if `gap` is negative, NaN, or infinite.
205 pub fn v_gap(mut self, gap: f64) -> Self {
206 if !gap.is_finite() || gap < 0.0 {
207 panic!("v_gap must be a finite non-negative number");
208 }
209 self.v_gap = Some(gap);
210 self
211 }
212
213 /// Sets the styling for facet labels.
214 ///
215 /// Controls the font, size, and color of the category labels that appear above each
216 /// facet. If not specified, plotly's default text styling is used.
217 ///
218 /// # Argument
219 ///
220 /// * `style` - A `Text` component or any type that can be converted into `Text`,
221 /// specifying the styling options for facet titles.
222 pub fn title_style<T: Into<Text>>(mut self, style: T) -> Self {
223 self.title_style = Some(style.into());
224 self
225 }
226
227 /// Sets a custom sorting function for facet order.
228 ///
229 /// By default, facets are ordered alphabetically by category name. This method allows
230 /// you to specify a custom comparison function to control the order in which facets
231 /// appear in the grid.
232 ///
233 /// # Argument
234 ///
235 /// * `f` - A function that takes two string slices and returns an `Ordering`,
236 /// following the same signature as `str::cmp`.
237 ///
238 /// # Example
239 ///
240 /// ```rust
241 /// use plotlars::FacetConfig;
242 /// use std::cmp::Ordering;
243 ///
244 /// // Reverse alphabetical order
245 /// let config = FacetConfig::new()
246 /// .sorter(|a, b| b.cmp(a));
247 /// ```
248 pub fn sorter(mut self, f: fn(&str, &str) -> Ordering) -> Self {
249 self.sorter = Some(f);
250 self
251 }
252
253 /// Enables or disables facet highlighting mode.
254 ///
255 /// When enabled, each facet shows all data from all categories, but emphasizes
256 /// the data for the current facet's category while displaying other categories
257 /// in a muted color. This provides visual context by showing the full data
258 /// distribution while focusing attention on the current facet.
259 ///
260 /// # Argument
261 ///
262 /// * `highlight` - A boolean value: `true` to enable highlighting, `false` to disable.
263 pub fn highlight_facet(mut self, highlight: bool) -> Self {
264 self.highlight_facet = highlight;
265 self
266 }
267
268 /// Sets the color for unhighlighted data points in highlighting mode.
269 ///
270 /// This setting only takes effect when `highlight_facet` is enabled. It specifies
271 /// the color used for data points that belong to other categories (not the current
272 /// facet's category). If not specified, a default grey color is used.
273 ///
274 /// # Argument
275 ///
276 /// * `color` - An `Rgb` value specifying the color for unhighlighted data.
277 pub fn unhighlighted_color(mut self, color: Rgb) -> Self {
278 self.unhighlighted_color = Some(color);
279 self
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn test_default() {
289 let fc = FacetConfig::new();
290 assert!(fc.rows.is_none());
291 assert!(fc.cols.is_none());
292 assert!(fc.h_gap.is_none());
293 assert!(fc.v_gap.is_none());
294 assert!(fc.title_style.is_none());
295 assert!(fc.sorter.is_none());
296 assert!(!fc.highlight_facet);
297 assert!(fc.unhighlighted_color.is_none());
298 }
299
300 #[test]
301 fn test_rows_valid() {
302 let fc = FacetConfig::new().rows(2);
303 assert_eq!(fc.rows, Some(2));
304 }
305
306 #[test]
307 #[should_panic(expected = "rows must be greater than 0")]
308 fn test_rows_zero_panics() {
309 FacetConfig::new().rows(0);
310 }
311
312 #[test]
313 fn test_cols_valid() {
314 let fc = FacetConfig::new().cols(3);
315 assert_eq!(fc.cols, Some(3));
316 }
317
318 #[test]
319 #[should_panic(expected = "cols must be greater than 0")]
320 fn test_cols_zero_panics() {
321 FacetConfig::new().cols(0);
322 }
323
324 #[test]
325 fn test_h_gap_valid() {
326 let fc = FacetConfig::new().h_gap(0.05);
327 assert!((fc.h_gap.unwrap() - 0.05).abs() < 1e-6);
328 }
329
330 #[test]
331 #[should_panic(expected = "h_gap must be a finite non-negative number")]
332 fn test_h_gap_negative_panics() {
333 FacetConfig::new().h_gap(-0.1);
334 }
335
336 #[test]
337 #[should_panic(expected = "h_gap must be a finite non-negative number")]
338 fn test_h_gap_nan_panics() {
339 FacetConfig::new().h_gap(f64::NAN);
340 }
341
342 #[test]
343 fn test_v_gap_valid() {
344 let fc = FacetConfig::new().v_gap(0.1);
345 assert!((fc.v_gap.unwrap() - 0.1).abs() < 1e-6);
346 }
347
348 #[test]
349 #[should_panic(expected = "v_gap must be a finite non-negative number")]
350 fn test_v_gap_negative_panics() {
351 FacetConfig::new().v_gap(-0.5);
352 }
353}