Skip to main content

ggplot_rs/facet/
wrap.rs

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