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    /// Like [`compute_full`](Self::compute_full), plus [`LayoutExtras`] for
100    /// the outer-label-fit left/right panel reservation (B3-1) and the
101    /// legend auto-wrap extra rows (B3-2). `extras` all-zero is byte-identical
102    /// to `compute_full`.
103    #[allow(clippy::too_many_arguments)]
104    pub(crate) fn compute_with_extras(
105        width: f64,
106        height: f64,
107        theme: &Theme,
108        has_title: bool,
109        has_subtitle: bool,
110        has_caption: bool,
111        has_legend: bool,
112        x_axis_top: bool,
113        y_label_width: Option<f64>,
114        x_label_height: Option<f64>,
115        extras: LayoutExtras,
116    ) -> Self {
117        let margin = &theme.plot_margin;
118
119        let title_height = if has_title {
120            theme.title.size * 2.0
121        } else {
122            0.0
123        };
124
125        let subtitle_height = if has_subtitle {
126            theme.subtitle.size * 1.5
127        } else {
128            0.0
129        };
130
131        let caption_height = if has_caption {
132            theme.caption.size * 1.8
133        } else {
134            0.0
135        };
136
137        let x_axis_height = theme.axis_ticks_length
138            + if theme.axis_text_x.visible {
139                // Rotated labels extend vertically, so reserve more bottom space —
140                // measured from the actual labels by the caller when available.
141                if theme.axis_text_x.angle.abs() > 10.0 {
142                    x_label_height.unwrap_or(theme.axis_text_x.size * 5.0)
143                } else {
144                    // Dodged labels stack across N rows.
145                    (theme.axis_text_x.size + 4.0) * theme.axis_text_x_dodge.max(1) as f64
146                }
147            } else {
148                0.0
149            }
150            + if theme.axis_title_x.visible {
151                theme.axis_title_x.size + 8.0
152            } else {
153                0.0
154            };
155
156        let y_axis_width = theme.axis_ticks_length
157            + if theme.axis_text_y.visible {
158                y_label_width.unwrap_or(theme.axis_text_y.size * 3.5 + 4.0)
159            } else {
160                0.0
161            }
162            + if theme.axis_title_y.visible {
163                theme.axis_title_y.size + 8.0
164            } else {
165                0.0
166            };
167
168        // #236: a mapped `size` legend's largest key can need a bigger box
169        // than the theme's flat defaults — grow the key (and so the band
170        // reserved for it) to fit, the same way `draw_discrete_legend_at`
171        // grows the key it actually draws. A no-op (both `.max()`s keep
172        // the theme defaults) when there's no size legend.
173        let key_w = theme.legend_key_width.max(extras.legend_max_key_size);
174        let key_h = theme.legend_key_height.max(key_w);
175
176        let legend_size = if has_legend {
177            theme.legend_margin.left
178                + key_w
179                + theme.legend_spacing
180                + theme.legend_text.size * 6.0
181                + theme.legend_margin.right
182                + extras.legend_extra_width
183        } else {
184            0.0
185        };
186
187        // A horizontal (top/bottom) legend only needs ONE row of height, not the
188        // width-based `legend_size` — otherwise a big gap opens above the plot.
189        let legend_band = if has_legend {
190            theme.legend_margin.top
191                + key_h
192                // #211: a horizontally-drawn continuous colourbar's own
193                // title/bar/ticks/labels can need more than the flat
194                // discrete-row height above — added once, not per extra
195                // row (a continuous colourbar never wraps rows).
196                + extras.continuous_horizontal_legend_extra_height
197                + theme.legend_spacing
198                + theme.legend_margin.bottom
199                + extras.legend_extra_rows as f64 * (key_h + theme.legend_spacing)
200        } else {
201            0.0
202        };
203        // Determine legend space allocation per position
204        let (legend_right, legend_left, legend_top, legend_bottom) = if has_legend {
205            match theme.legend_position {
206                LegendPosition::Right => (legend_size, 0.0, 0.0, 0.0),
207                LegendPosition::Left => (0.0, legend_size, 0.0, 0.0),
208                LegendPosition::Top => (0.0, 0.0, legend_band, 0.0),
209                LegendPosition::Bottom => (0.0, 0.0, 0.0, legend_band),
210                // Inside/None overlay the panel, reserving no external space.
211                LegendPosition::None | LegendPosition::Inside(..) => (0.0, 0.0, 0.0, 0.0),
212            }
213        } else {
214            (0.0, 0.0, 0.0, 0.0)
215        };
216
217        let plot_x = margin.left + y_axis_width + legend_left + extras.left;
218        // A top x-axis reserves its space above the panel instead of below.
219        let plot_y = margin.top
220            + title_height
221            + subtitle_height
222            + legend_top
223            + if x_axis_top { x_axis_height } else { 0.0 };
224        let plot_width = width
225            - margin.left
226            - margin.right
227            - y_axis_width
228            - legend_right
229            - legend_left
230            - extras.left
231            - extras.right;
232        let plot_height = height
233            - margin.top
234            - margin.bottom
235            - title_height
236            - subtitle_height
237            - caption_height
238            - x_axis_height
239            - legend_top
240            - legend_bottom;
241
242        let mut plot_width = plot_width.max(50.0);
243        let mut plot_height = plot_height.max(50.0);
244        let mut plot_x = plot_x;
245        let mut plot_y = plot_y;
246
247        // Fix the panel aspect ratio (R's `aspect.ratio` = height/width),
248        // shrinking the larger dimension and centering within the available box.
249        if let Some(r) = theme.aspect_ratio {
250            if r > 0.0 {
251                let (avail_w, avail_h) = (plot_width, plot_height);
252                if avail_w * r <= avail_h {
253                    plot_height = avail_w * r;
254                    plot_y += (avail_h - plot_height) / 2.0;
255                } else {
256                    plot_width = avail_h / r;
257                    plot_x += (avail_w - plot_width) / 2.0;
258                }
259            }
260        }
261
262        PlotLayout {
263            total: Rect {
264                x: 0.0,
265                y: 0.0,
266                width,
267                height,
268            },
269            plot_area: Rect {
270                x: plot_x,
271                y: plot_y,
272                width: plot_width,
273                height: plot_height,
274            },
275            title_area: Rect {
276                x: plot_x,
277                y: margin.top,
278                width: plot_width,
279                height: title_height,
280            },
281            subtitle_area: Rect {
282                x: plot_x,
283                y: margin.top + title_height,
284                width: plot_width,
285                height: subtitle_height,
286            },
287            caption_area: Rect {
288                x: plot_x,
289                y: plot_y + plot_height + x_axis_height,
290                width: plot_width,
291                height: caption_height,
292            },
293            x_axis_area: Rect {
294                x: plot_x,
295                y: plot_y + plot_height,
296                width: plot_width,
297                height: x_axis_height,
298            },
299            y_axis_area: Rect {
300                x: margin.left + legend_left,
301                y: plot_y,
302                width: y_axis_width,
303                height: plot_height,
304            },
305            legend_area: Rect {
306                x: plot_x + plot_width,
307                y: plot_y,
308                width: legend_size,
309                height: plot_height,
310            },
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::theme::presets::theme_gray;
319
320    /// `compute_full` is kept as a public byte-identical shim over
321    /// `compute_with_extras(..., LayoutExtras::default())` (B3-1) — no
322    /// longer called internally (`layout_built` calls `compute_with_extras`
323    /// directly), but still part of the public API.
324    #[test]
325    fn compute_full_matches_compute_with_extras_default() {
326        let theme = theme_gray();
327        let a = PlotLayout::compute_full(
328            640.0, 400.0, &theme, true, false, false, true, false, None, None,
329        );
330        let b = PlotLayout::compute_with_extras(
331            640.0,
332            400.0,
333            &theme,
334            true,
335            false,
336            false,
337            true,
338            false,
339            None,
340            None,
341            LayoutExtras::default(),
342        );
343        assert_eq!(a.plot_area.x, b.plot_area.x);
344        assert_eq!(a.plot_area.width, b.plot_area.width);
345        assert_eq!(a.plot_area.height, b.plot_area.height);
346    }
347
348    #[test]
349    fn compute_delegates_to_compute_full() {
350        let theme = theme_gray();
351        let a = PlotLayout::compute(640.0, 400.0, &theme, true, false);
352        let b = PlotLayout::compute_full(
353            640.0, 400.0, &theme, true, false, false, false, false, None, None,
354        );
355        assert_eq!(a.plot_area.x, b.plot_area.x);
356    }
357}