Skip to main content

ggplot_rs/facet/
wrap.rs

1use crate::render::Rect;
2
3use super::Panel;
4
5/// `(row, col)` of the `i`-th of `n` wrap panels in an `ncol`-column grid:
6/// filled row by row (`vertical = false`, ggplot2's `dir = "h"`) or column
7/// by column (`dir = "v"`), top to bottom unless `as_table = false`
8/// (ggplot2's `as.table`), which puts the first row at the bottom (#412).
9pub fn wrap_cell(
10    i: usize,
11    n: usize,
12    ncol: usize,
13    vertical: bool,
14    as_table: bool,
15) -> (usize, usize) {
16    let ncol = ncol.max(1);
17    let nrow = n.div_ceil(ncol).max(1);
18    let (row, col) = if vertical {
19        (i % nrow, i / nrow)
20    } else {
21        (i / ncol, i % ncol)
22    };
23    (if as_table { row } else { nrow - 1 - row }, col)
24}
25
26/// Compute panel layout for facet_wrap.
27pub fn compute_wrap_panels(
28    levels: &[String],
29    ncol: Option<usize>,
30    total_area: &Rect,
31    strip_height: f64,
32) -> Vec<Panel> {
33    let n = levels.len();
34    if n == 0 {
35        return vec![];
36    }
37
38    let ncol = ncol.unwrap_or_else(|| {
39        let sqrt = (n as f64).sqrt().ceil() as usize;
40        sqrt.max(1)
41    });
42    let nrow = n.div_ceil(ncol);
43
44    let panel_width = total_area.width / ncol as f64;
45    let panel_height = total_area.height / nrow as f64;
46
47    levels
48        .iter()
49        .enumerate()
50        .map(|(i, label)| {
51            let row = i / ncol;
52            let col = i % ncol;
53
54            Panel {
55                row,
56                col,
57                label: label.clone(),
58                row_label: None,
59                col_label: Some(label.clone()),
60                rect: Rect {
61                    x: total_area.x + col as f64 * panel_width,
62                    y: total_area.y + row as f64 * panel_height + strip_height,
63                    width: panel_width,
64                    height: panel_height - strip_height,
65                },
66            }
67        })
68        .collect()
69}
70
71/// A `facet_wrap` grid of equal, fixed-aspect panels (e.g. maps under
72/// `coord_sf`): the column/row count and each panel's size.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct FacetGrid {
75    pub ncol: usize,
76    pub nrow: usize,
77    pub panel_w: f64,
78    pub panel_h: f64,
79}
80
81/// Lay `n` panels of aspect `aspect` (height / width) into an
82/// `area_w × area_h` facet area, each panel topped by a `strip_h` strip and
83/// separated by `gap_x` / `gap_y`. With `ncol` = `None` the column count is
84/// the one that gives the largest panels (ties: fewer rows); a given `ncol`
85/// is clamped to `1..=n`. Every panel is `panel_w × panel_h`, with
86/// `panel_h = panel_w × aspect`.
87#[allow(clippy::too_many_arguments)]
88pub fn fit_grid(
89    n: usize,
90    ncol: Option<usize>,
91    aspect: f64,
92    area_w: f64,
93    area_h: f64,
94    strip_h: f64,
95    gap_x: f64,
96    gap_y: f64,
97) -> FacetGrid {
98    let n = n.max(1);
99    let aspect = if aspect.is_finite() && aspect > 0.0 {
100        aspect
101    } else {
102        1.0
103    };
104    let size = |c: usize| {
105        let r = n.div_ceil(c);
106        let pw = (area_w - gap_x * (c as f64 - 1.0)) / c as f64;
107        let ph = (area_h - r as f64 * strip_h - gap_y * (r as f64 - 1.0)) / r as f64;
108        (pw.min(ph / aspect)).max(0.0)
109    };
110    let c = match ncol {
111        Some(c) => c.clamp(1, n),
112        None => {
113            let mut best = (1, size(1));
114            for c in 2..=n {
115                let s = size(c);
116                // on a tie, the grid with fewer rows wins
117                if s > best.1 + 1e-9
118                    || ((s - best.1).abs() <= 1e-9 && n.div_ceil(c) < n.div_ceil(best.0))
119                {
120                    best = (c, s);
121                }
122            }
123            best.0
124        }
125    };
126    let s = size(c);
127    FacetGrid {
128        ncol: c,
129        nrow: n.div_ceil(c),
130        panel_w: s,
131        panel_h: s * aspect,
132    }
133}
134
135/// The natural height of a `facet_wrap` area of fixed-aspect panels at
136/// `width`: the column count (a given `ncol`, else as many columns as keep
137/// each panel at least `min_panel_w` wide, at most `n`) and
138/// `nrow × (panel_h + strip_h) + (nrow − 1) × gap_y` for panels filling the
139/// width. Lets a host size a faceted map before rendering it.
140#[allow(clippy::too_many_arguments)]
141pub fn natural_height(
142    n: usize,
143    ncol: Option<usize>,
144    aspect: f64,
145    width: f64,
146    strip_h: f64,
147    gap_x: f64,
148    gap_y: f64,
149    min_panel_w: f64,
150) -> (usize, f64) {
151    let n = n.max(1);
152    let aspect = if aspect.is_finite() && aspect > 0.0 {
153        aspect
154    } else {
155        1.0
156    };
157    let c = match ncol {
158        Some(c) => c.clamp(1, n),
159        None => (((width + gap_x) / (min_panel_w + gap_x)).floor() as usize).clamp(1, n),
160    };
161    let r = n.div_ceil(c);
162    let pw = ((width - gap_x * (c as f64 - 1.0)) / c as f64).max(0.0);
163    (
164        c,
165        r as f64 * (pw * aspect + strip_h) + gap_y * (r as f64 - 1.0),
166    )
167}
168
169#[cfg(test)]
170mod grid_tests {
171    use super::*;
172
173    #[test]
174    fn fit_grid_picks_the_largest_panels() {
175        // wide area, landscape-ish maps (aspect 1.3 = taller than wide): one row
176        let g = fit_grid(4, None, 1.3, 1200.0, 400.0, 0.0, 0.0, 0.0);
177        assert_eq!((g.ncol, g.nrow), (4, 1));
178        // square area: 2×2
179        let g = fit_grid(4, None, 1.0, 600.0, 600.0, 0.0, 0.0, 0.0);
180        assert_eq!((g.ncol, g.nrow), (2, 2));
181        assert!((g.panel_w - 300.0).abs() < 1e-9 && (g.panel_h - 300.0).abs() < 1e-9);
182    }
183
184    #[test]
185    fn fit_grid_respects_strips_gaps_and_a_given_ncol() {
186        let g = fit_grid(3, Some(3), 0.5, 620.0, 400.0, 20.0, 10.0, 10.0);
187        assert_eq!((g.ncol, g.nrow), (3, 1));
188        assert!((g.panel_w - 200.0).abs() < 1e-9);
189        assert!((g.panel_h - 100.0).abs() < 1e-9);
190        // ncol is clamped to n
191        assert_eq!(
192            fit_grid(2, Some(9), 1.0, 100.0, 100.0, 0.0, 0.0, 0.0).ncol,
193            2
194        );
195        // height-bound: panels shrink to fit the rows
196        let g = fit_grid(2, Some(1), 1.0, 1000.0, 220.0, 10.0, 0.0, 0.0);
197        assert!((g.panel_h - 100.0).abs() < 1e-9 && (g.panel_w - 100.0).abs() < 1e-9);
198    }
199
200    #[test]
201    fn natural_height_fills_the_width() {
202        // 4 panels, min 160 px, 700 px wide, gaps 10: 4 columns fit (4×160+30=670)
203        let (c, h) = natural_height(4, None, 1.0, 700.0, 20.0, 10.0, 10.0, 160.0);
204        assert_eq!(c, 4);
205        assert!((h - (167.5 + 20.0)).abs() < 1e-9);
206        // 390 px phone: 2 columns, 2 rows
207        let (c, h) = natural_height(4, None, 1.0, 390.0, 20.0, 10.0, 10.0, 160.0);
208        assert_eq!(c, 2);
209        assert!((h - (2.0 * (190.0 + 20.0) + 10.0)).abs() < 1e-9);
210        // a fixed ncol wins
211        assert_eq!(
212            natural_height(4, Some(1), 1.0, 700.0, 0.0, 0.0, 0.0, 160.0).0,
213            1
214        );
215    }
216}
217
218#[cfg(test)]
219mod cell_tests {
220    use super::wrap_cell;
221
222    #[test]
223    fn dir_and_as_table_match_ggplot2() {
224        // 5 panels, 3 columns → 2 rows. dir = "h": a b c / d e.
225        let h: Vec<_> = (0..5).map(|i| wrap_cell(i, 5, 3, false, true)).collect();
226        assert_eq!(h, [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1)]);
227        // dir = "v": a c e / b d.
228        let v: Vec<_> = (0..5).map(|i| wrap_cell(i, 5, 3, true, true)).collect();
229        assert_eq!(v, [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]);
230        // as.table = FALSE: the first row at the bottom.
231        let b: Vec<_> = (0..5).map(|i| wrap_cell(i, 5, 3, false, false)).collect();
232        assert_eq!(b, [(1, 0), (1, 1), (1, 2), (0, 0), (0, 1)]);
233    }
234}