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