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}