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}
33
34impl PlotLayout {
35    /// Compute layout from total dimensions and theme settings.
36    pub fn compute(
37        width: f64,
38        height: f64,
39        theme: &Theme,
40        has_title: bool,
41        has_legend: bool,
42    ) -> Self {
43        Self::compute_full(
44            width, height, theme, has_title, false, false, has_legend, false, None, None,
45        )
46    }
47
48    /// Compute layout with full subtitle/caption support.
49    ///
50    /// `y_label_width` optionally overrides the reserved width (px) of the
51    /// y-axis tick-label text — measured from the actual break labels by the
52    /// caller so long categorical labels (e.g. a flipped bar chart's rows)
53    /// aren't clipped. `None` keeps the fixed `axis_text_y.size * 3.5` estimate.
54    #[allow(clippy::too_many_arguments)]
55    pub fn compute_full(
56        width: f64,
57        height: f64,
58        theme: &Theme,
59        has_title: bool,
60        has_subtitle: bool,
61        has_caption: bool,
62        has_legend: bool,
63        x_axis_top: bool,
64        y_label_width: Option<f64>,
65        x_label_height: Option<f64>,
66    ) -> Self {
67        Self::compute_with_extras(
68            width,
69            height,
70            theme,
71            has_title,
72            has_subtitle,
73            has_caption,
74            has_legend,
75            x_axis_top,
76            y_label_width,
77            x_label_height,
78            LayoutExtras::default(),
79        )
80    }
81
82    /// Like [`compute_full`](Self::compute_full), plus [`LayoutExtras`] for
83    /// the outer-label-fit left/right panel reservation (B3-1) and the
84    /// legend auto-wrap extra rows (B3-2). `extras` all-zero is byte-identical
85    /// to `compute_full`.
86    #[allow(clippy::too_many_arguments)]
87    pub(crate) fn compute_with_extras(
88        width: f64,
89        height: f64,
90        theme: &Theme,
91        has_title: bool,
92        has_subtitle: bool,
93        has_caption: bool,
94        has_legend: bool,
95        x_axis_top: bool,
96        y_label_width: Option<f64>,
97        x_label_height: Option<f64>,
98        extras: LayoutExtras,
99    ) -> Self {
100        let margin = &theme.plot_margin;
101
102        let title_height = if has_title {
103            theme.title.size * 2.0
104        } else {
105            0.0
106        };
107
108        let subtitle_height = if has_subtitle {
109            theme.subtitle.size * 1.5
110        } else {
111            0.0
112        };
113
114        let caption_height = if has_caption {
115            theme.caption.size * 1.8
116        } else {
117            0.0
118        };
119
120        let x_axis_height = theme.axis_ticks_length
121            + if theme.axis_text_x.visible {
122                // Rotated labels extend vertically, so reserve more bottom space —
123                // measured from the actual labels by the caller when available.
124                if theme.axis_text_x.angle.abs() > 10.0 {
125                    x_label_height.unwrap_or(theme.axis_text_x.size * 5.0)
126                } else {
127                    // Dodged labels stack across N rows.
128                    (theme.axis_text_x.size + 4.0) * theme.axis_text_x_dodge.max(1) as f64
129                }
130            } else {
131                0.0
132            }
133            + if theme.axis_title_x.visible {
134                theme.axis_title_x.size + 8.0
135            } else {
136                0.0
137            };
138
139        let y_axis_width = theme.axis_ticks_length
140            + if theme.axis_text_y.visible {
141                y_label_width.unwrap_or(theme.axis_text_y.size * 3.5 + 4.0)
142            } else {
143                0.0
144            }
145            + if theme.axis_title_y.visible {
146                theme.axis_title_y.size + 8.0
147            } else {
148                0.0
149            };
150
151        let legend_size = if has_legend {
152            theme.legend_margin.left
153                + theme.legend_key_width
154                + theme.legend_spacing
155                + theme.legend_text.size * 6.0
156                + theme.legend_margin.right
157                + extras.legend_extra_width
158        } else {
159            0.0
160        };
161
162        // A horizontal (top/bottom) legend only needs ONE row of height, not the
163        // width-based `legend_size` — otherwise a big gap opens above the plot.
164        let legend_band = if has_legend {
165            theme.legend_margin.top
166                + theme.legend_key_height
167                + theme.legend_spacing
168                + theme.legend_margin.bottom
169                + extras.legend_extra_rows as f64 * (theme.legend_key_height + theme.legend_spacing)
170        } else {
171            0.0
172        };
173        // Determine legend space allocation per position
174        let (legend_right, legend_left, legend_top, legend_bottom) = if has_legend {
175            match theme.legend_position {
176                LegendPosition::Right => (legend_size, 0.0, 0.0, 0.0),
177                LegendPosition::Left => (0.0, legend_size, 0.0, 0.0),
178                LegendPosition::Top => (0.0, 0.0, legend_band, 0.0),
179                LegendPosition::Bottom => (0.0, 0.0, 0.0, legend_band),
180                // Inside/None overlay the panel, reserving no external space.
181                LegendPosition::None | LegendPosition::Inside(..) => (0.0, 0.0, 0.0, 0.0),
182            }
183        } else {
184            (0.0, 0.0, 0.0, 0.0)
185        };
186
187        let plot_x = margin.left + y_axis_width + legend_left + extras.left;
188        // A top x-axis reserves its space above the panel instead of below.
189        let plot_y = margin.top
190            + title_height
191            + subtitle_height
192            + legend_top
193            + if x_axis_top { x_axis_height } else { 0.0 };
194        let plot_width = width
195            - margin.left
196            - margin.right
197            - y_axis_width
198            - legend_right
199            - legend_left
200            - extras.left
201            - extras.right;
202        let plot_height = height
203            - margin.top
204            - margin.bottom
205            - title_height
206            - subtitle_height
207            - caption_height
208            - x_axis_height
209            - legend_top
210            - legend_bottom;
211
212        let mut plot_width = plot_width.max(50.0);
213        let mut plot_height = plot_height.max(50.0);
214        let mut plot_x = plot_x;
215        let mut plot_y = plot_y;
216
217        // Fix the panel aspect ratio (R's `aspect.ratio` = height/width),
218        // shrinking the larger dimension and centering within the available box.
219        if let Some(r) = theme.aspect_ratio {
220            if r > 0.0 {
221                let (avail_w, avail_h) = (plot_width, plot_height);
222                if avail_w * r <= avail_h {
223                    plot_height = avail_w * r;
224                    plot_y += (avail_h - plot_height) / 2.0;
225                } else {
226                    plot_width = avail_h / r;
227                    plot_x += (avail_w - plot_width) / 2.0;
228                }
229            }
230        }
231
232        PlotLayout {
233            total: Rect {
234                x: 0.0,
235                y: 0.0,
236                width,
237                height,
238            },
239            plot_area: Rect {
240                x: plot_x,
241                y: plot_y,
242                width: plot_width,
243                height: plot_height,
244            },
245            title_area: Rect {
246                x: plot_x,
247                y: margin.top,
248                width: plot_width,
249                height: title_height,
250            },
251            subtitle_area: Rect {
252                x: plot_x,
253                y: margin.top + title_height,
254                width: plot_width,
255                height: subtitle_height,
256            },
257            caption_area: Rect {
258                x: plot_x,
259                y: plot_y + plot_height + x_axis_height,
260                width: plot_width,
261                height: caption_height,
262            },
263            x_axis_area: Rect {
264                x: plot_x,
265                y: plot_y + plot_height,
266                width: plot_width,
267                height: x_axis_height,
268            },
269            y_axis_area: Rect {
270                x: margin.left + legend_left,
271                y: plot_y,
272                width: y_axis_width,
273                height: plot_height,
274            },
275            legend_area: Rect {
276                x: plot_x + plot_width,
277                y: plot_y,
278                width: legend_size,
279                height: plot_height,
280            },
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::theme::presets::theme_gray;
289
290    /// `compute_full` is kept as a public byte-identical shim over
291    /// `compute_with_extras(..., LayoutExtras::default())` (B3-1) — no
292    /// longer called internally (`layout_built` calls `compute_with_extras`
293    /// directly), but still part of the public API.
294    #[test]
295    fn compute_full_matches_compute_with_extras_default() {
296        let theme = theme_gray();
297        let a = PlotLayout::compute_full(
298            640.0, 400.0, &theme, true, false, false, true, false, None, None,
299        );
300        let b = PlotLayout::compute_with_extras(
301            640.0,
302            400.0,
303            &theme,
304            true,
305            false,
306            false,
307            true,
308            false,
309            None,
310            None,
311            LayoutExtras::default(),
312        );
313        assert_eq!(a.plot_area.x, b.plot_area.x);
314        assert_eq!(a.plot_area.width, b.plot_area.width);
315        assert_eq!(a.plot_area.height, b.plot_area.height);
316    }
317
318    #[test]
319    fn compute_delegates_to_compute_full() {
320        let theme = theme_gray();
321        let a = PlotLayout::compute(640.0, 400.0, &theme, true, false);
322        let b = PlotLayout::compute_full(
323            640.0, 400.0, &theme, true, false, false, false, false, None, None,
324        );
325        assert_eq!(a.plot_area.x, b.plot_area.x);
326    }
327}