Skip to main content

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/// ![Example](https://imgur.com/nHdLCAB.png)
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}