Skip to main content

ggplot_rs/render/
layout.rs

1use crate::theme::{LegendPosition, Theme};
2
3use super::Rect;
4
5/// Computed layout areas for the plot.
6pub struct PlotLayout {
7    pub total: Rect,
8    pub plot_area: Rect,
9    pub title_area: Rect,
10    pub subtitle_area: Rect,
11    pub caption_area: Rect,
12    pub x_axis_area: Rect,
13    pub y_axis_area: Rect,
14    pub legend_area: Rect,
15}
16
17/// Extra layout reservations beyond the theme-driven defaults (B3-1/B3-2):
18/// additional left/right panel margin to keep outer x-axis labels on-canvas
19/// under [`AxisLabelFit::Reserve`](crate::theme::AxisLabelFit::Reserve), and
20/// extra legend rows from the B3-2 auto-wrap/`nrow`/`ncol` planner. All-zero
21/// (the `Default`) reproduces `compute_full`'s own byte-identical math.
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
23pub(crate) struct LayoutExtras {
24    pub left: f64,
25    pub right: f64,
26    pub legend_extra_rows: usize,
27    /// Extra legend band width (B3-2, review #158 finding 3): a vertical
28    /// explicit grid with `ncol > 1` needs `(cols − 1) · col_pitch` beyond
29    /// the single-column `legend_size` estimate, or its later columns run
30    /// off the canvas.
31    pub legend_extra_width: f64,
32    /// #236: the largest mapped `size` legend key's diameter, across every
33    /// `size`-mapped scale being drawn — `0.0` (the default) when there's
34    /// no size legend, or its largest key already fits the theme's
35    /// `legend_key_width`/`legend_key_height`. `legend_size`/`legend_band`
36    /// grow the key box (and the band reserved for it) to this instead of
37    /// the flat theme default, the same way `draw_discrete_legend_at`
38    /// grows the key it actually draws — otherwise a key too big for the
39    /// reserved band spills into the panel.
40    pub legend_max_key_size: f64,
41    /// #211: how much taller the Top/Bottom legend band must be, beyond
42    /// the flat discrete-row height (`key_h`) `legend_band` already
43    /// assumes, to fit a horizontally-drawn continuous colourbar's
44    /// title/bar/ticks/labels (see
45    /// [`continuous_horizontal_bar_height`](crate::guide::legend::continuous_horizontal_bar_height)).
46    /// `0.0` (the default) when there's no continuous color/fill legend
47    /// drawn horizontally, or its own band already fits in `key_h`.
48    pub continuous_horizontal_legend_extra_height: f64,
49}
50
51impl PlotLayout {
52    /// Compute layout from total dimensions and theme settings.
53    pub fn compute(
54        width: f64,
55        height: f64,
56        theme: &Theme,
57        has_title: bool,
58        has_legend: bool,
59    ) -> Self {
60        Self::compute_full(
61            width, height, theme, has_title, false, false, has_legend, false, None, None,
62        )
63    }
64
65    /// Compute layout with full subtitle/caption support.
66    ///
67    /// `y_label_width` optionally overrides the reserved width (px) of the
68    /// y-axis tick-label text — measured from the actual break labels by the
69    /// caller so long categorical labels (e.g. a flipped bar chart's rows)
70    /// aren't clipped. `None` keeps the fixed `axis_text_y.size * 3.5` estimate.
71    #[allow(clippy::too_many_arguments)]
72    pub fn compute_full(
73        width: f64,
74        height: f64,
75        theme: &Theme,
76        has_title: bool,
77        has_subtitle: bool,
78        has_caption: bool,
79        has_legend: bool,
80        x_axis_top: bool,
81        y_label_width: Option<f64>,
82        x_label_height: Option<f64>,
83    ) -> Self {
84        Self::compute_with_extras(
85            width,
86            height,
87            theme,
88            has_title,
89            has_subtitle,
90            has_caption,
91            has_legend,
92            x_axis_top,
93            y_label_width,
94            x_label_height,
95            LayoutExtras::default(),
96        )
97    }
98
99    /// Height the layout reserves for the bottom x axis (ticks, labels,
100    /// title); `x_label_height` is the measured rotated-label height, if any.
101    pub(crate) fn x_axis_band(theme: &Theme, x_label_height: Option<f64>) -> f64 {
102        theme.axis_ticks_length
103            + if theme.axis_text_x.visible {
104                // Rotated labels extend vertically, so reserve more bottom space —
105                // measured from the actual labels by the caller when available.
106                if theme.axis_text_x.angle.abs() > 10.0 {
107                    x_label_height.unwrap_or(theme.axis_text_x.size * 5.0)
108                } else {
109                    // Dodged labels stack across N rows.
110                    (theme.axis_text_x.size + 4.0) * theme.axis_text_x_dodge.max(1) as f64
111                }
112            } else {
113                0.0
114            }
115            + if theme.axis_title_x.visible {
116                theme.axis_title_x.size + 8.0
117            } else {
118                0.0
119            }
120    }
121
122    /// Like [`compute_full`](Self::compute_full), plus [`LayoutExtras`] for
123    /// the outer-label-fit left/right panel reservation (B3-1) and the
124    /// legend auto-wrap extra rows (B3-2). `extras` all-zero is byte-identical
125    /// to `compute_full`.
126    #[allow(clippy::too_many_arguments)]
127    pub(crate) fn compute_with_extras(
128        width: f64,
129        height: f64,
130        theme: &Theme,
131        has_title: bool,
132        has_subtitle: bool,
133        has_caption: bool,
134        has_legend: bool,
135        x_axis_top: bool,
136        y_label_width: Option<f64>,
137        x_label_height: Option<f64>,
138        extras: LayoutExtras,
139    ) -> Self {
140        let margin = &theme.plot_margin;
141
142        let title_height = if has_title {
143            theme.title.size * 2.0
144        } else {
145            0.0
146        };
147
148        let subtitle_height = if has_subtitle {
149            theme.subtitle.size * 1.5
150        } else {
151            0.0
152        };
153
154        let caption_height = if has_caption {
155            theme.caption.size * 1.8
156        } else {
157            0.0
158        };
159
160        let x_axis_height = Self::x_axis_band(theme, x_label_height);
161
162        let y_axis_width = theme.axis_ticks_length
163            + if theme.axis_text_y.visible {
164                y_label_width.unwrap_or(theme.axis_text_y.size * 3.5 + 4.0)
165            } else {
166                0.0
167            }
168            + if theme.axis_title_y.visible {
169                theme.axis_title_y.size + 8.0
170            } else {
171                0.0
172            };
173
174        // #236: a mapped `size` legend's largest key can need a bigger box
175        // than the theme's flat defaults — grow the key (and so the band
176        // reserved for it) to fit, the same way `draw_discrete_legend_at`
177        // grows the key it actually draws. A no-op (both `.max()`s keep
178        // the theme defaults) when there's no size legend.
179        let key_w = theme.legend_key_width.max(extras.legend_max_key_size);
180        let key_h = theme.legend_key_height.max(key_w);
181
182        let legend_size = if has_legend {
183            theme.legend_margin.left
184                + key_w
185                + theme.legend_spacing
186                + theme.legend_text.size * 6.0
187                + theme.legend_margin.right
188                + extras.legend_extra_width
189        } else {
190            0.0
191        };
192
193        // A horizontal (top/bottom) legend only needs ONE row of height, not the
194        // width-based `legend_size` — otherwise a big gap opens above the plot.
195        let legend_band = if has_legend {
196            theme.legend_margin.top
197                + key_h
198                // #211: a horizontally-drawn continuous colourbar's own
199                // title/bar/ticks/labels can need more than the flat
200                // discrete-row height above — added once, not per extra
201                // row (a continuous colourbar never wraps rows).
202                + extras.continuous_horizontal_legend_extra_height
203                + theme.legend_spacing
204                + theme.legend_margin.bottom
205                + extras.legend_extra_rows as f64 * (key_h + theme.legend_spacing)
206        } else {
207            0.0
208        };
209        // Determine legend space allocation per position
210        let (legend_right, legend_left, legend_top, legend_bottom) = if has_legend {
211            match theme.legend_position {
212                LegendPosition::Right => (legend_size, 0.0, 0.0, 0.0),
213                LegendPosition::Left => (0.0, legend_size, 0.0, 0.0),
214                LegendPosition::Top => (0.0, 0.0, legend_band, 0.0),
215                LegendPosition::Bottom => (0.0, 0.0, 0.0, legend_band),
216                // Inside/None overlay the panel, reserving no external space.
217                LegendPosition::None | LegendPosition::Inside(..) => (0.0, 0.0, 0.0, 0.0),
218            }
219        } else {
220            (0.0, 0.0, 0.0, 0.0)
221        };
222
223        let plot_x = margin.left + y_axis_width + legend_left + extras.left;
224        // A top x-axis reserves its space above the panel instead of below.
225        let plot_y = margin.top
226            + title_height
227            + subtitle_height
228            + legend_top
229            + if x_axis_top { x_axis_height } else { 0.0 };
230        let plot_width = width
231            - margin.left
232            - margin.right
233            - y_axis_width
234            - legend_right
235            - legend_left
236            - extras.left
237            - extras.right;
238        let plot_height = height
239            - margin.top
240            - margin.bottom
241            - title_height
242            - subtitle_height
243            - caption_height
244            - x_axis_height
245            - legend_top
246            - legend_bottom;
247
248        let mut plot_width = plot_width.max(50.0);
249        let mut plot_height = plot_height.max(50.0);
250        let mut plot_x = plot_x;
251        let mut plot_y = plot_y;
252
253        // Fix the panel aspect ratio (R's `aspect.ratio` = height/width),
254        // shrinking the larger dimension and centering within the available box.
255        if let Some(r) = theme.aspect_ratio {
256            if r > 0.0 {
257                let (avail_w, avail_h) = (plot_width, plot_height);
258                if avail_w * r <= avail_h {
259                    plot_height = avail_w * r;
260                    plot_y += (avail_h - plot_height) / 2.0;
261                } else {
262                    plot_width = avail_h / r;
263                    plot_x += (avail_w - plot_width) / 2.0;
264                }
265            }
266        }
267
268        PlotLayout {
269            total: Rect {
270                x: 0.0,
271                y: 0.0,
272                width,
273                height,
274            },
275            plot_area: Rect {
276                x: plot_x,
277                y: plot_y,
278                width: plot_width,
279                height: plot_height,
280            },
281            title_area: Rect {
282                x: plot_x,
283                y: margin.top,
284                width: plot_width,
285                height: title_height,
286            },
287            subtitle_area: Rect {
288                x: plot_x,
289                y: margin.top + title_height,
290                width: plot_width,
291                height: subtitle_height,
292            },
293            caption_area: Rect {
294                x: plot_x,
295                y: plot_y + plot_height + x_axis_height,
296                width: plot_width,
297                height: caption_height,
298            },
299            x_axis_area: Rect {
300                x: plot_x,
301                y: plot_y + plot_height,
302                width: plot_width,
303                height: x_axis_height,
304            },
305            y_axis_area: Rect {
306                x: margin.left + legend_left,
307                y: plot_y,
308                width: y_axis_width,
309                height: plot_height,
310            },
311            legend_area: Rect {
312                x: plot_x + plot_width,
313                y: plot_y,
314                width: legend_size,
315                height: plot_height,
316            },
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::theme::presets::theme_gray;
325
326    /// `compute_full` is kept as a public byte-identical shim over
327    /// `compute_with_extras(..., LayoutExtras::default())` (B3-1) — no
328    /// longer called internally (`layout_built` calls `compute_with_extras`
329    /// directly), but still part of the public API.
330    #[test]
331    fn compute_full_matches_compute_with_extras_default() {
332        let theme = theme_gray();
333        let a = PlotLayout::compute_full(
334            640.0, 400.0, &theme, true, false, false, true, false, None, None,
335        );
336        let b = PlotLayout::compute_with_extras(
337            640.0,
338            400.0,
339            &theme,
340            true,
341            false,
342            false,
343            true,
344            false,
345            None,
346            None,
347            LayoutExtras::default(),
348        );
349        assert_eq!(a.plot_area.x, b.plot_area.x);
350        assert_eq!(a.plot_area.width, b.plot_area.width);
351        assert_eq!(a.plot_area.height, b.plot_area.height);
352    }
353
354    #[test]
355    fn compute_delegates_to_compute_full() {
356        let theme = theme_gray();
357        let a = PlotLayout::compute(640.0, 400.0, &theme, true, false);
358        let b = PlotLayout::compute_full(
359            640.0, 400.0, &theme, true, false, false, false, false, None, None,
360        );
361        assert_eq!(a.plot_area.x, b.plot_area.x);
362    }
363}