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