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