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
17impl PlotLayout {
18    /// Compute layout from total dimensions and theme settings.
19    pub fn compute(
20        width: f64,
21        height: f64,
22        theme: &Theme,
23        has_title: bool,
24        has_legend: bool,
25    ) -> Self {
26        Self::compute_full(
27            width, height, theme, has_title, false, false, has_legend, false, None, None,
28        )
29    }
30
31    /// Compute layout with full subtitle/caption support.
32    ///
33    /// `y_label_width` optionally overrides the reserved width (px) of the
34    /// y-axis tick-label text — measured from the actual break labels by the
35    /// caller so long categorical labels (e.g. a flipped bar chart's rows)
36    /// aren't clipped. `None` keeps the fixed `axis_text_y.size * 3.5` estimate.
37    #[allow(clippy::too_many_arguments)]
38    pub fn compute_full(
39        width: f64,
40        height: f64,
41        theme: &Theme,
42        has_title: bool,
43        has_subtitle: bool,
44        has_caption: bool,
45        has_legend: bool,
46        x_axis_top: bool,
47        y_label_width: Option<f64>,
48        x_label_height: Option<f64>,
49    ) -> Self {
50        let margin = &theme.plot_margin;
51
52        let title_height = if has_title {
53            theme.title.size * 2.0
54        } else {
55            0.0
56        };
57
58        let subtitle_height = if has_subtitle {
59            theme.subtitle.size * 1.5
60        } else {
61            0.0
62        };
63
64        let caption_height = if has_caption {
65            theme.caption.size * 1.8
66        } else {
67            0.0
68        };
69
70        let x_axis_height = theme.axis_ticks_length
71            + if theme.axis_text_x.visible {
72                // Rotated labels extend vertically, so reserve more bottom space —
73                // measured from the actual labels by the caller when available.
74                if theme.axis_text_x.angle.abs() > 10.0 {
75                    x_label_height.unwrap_or(theme.axis_text_x.size * 5.0)
76                } else {
77                    // Dodged labels stack across N rows.
78                    (theme.axis_text_x.size + 4.0) * theme.axis_text_x_dodge.max(1) as f64
79                }
80            } else {
81                0.0
82            }
83            + if theme.axis_title_x.visible {
84                theme.axis_title_x.size + 8.0
85            } else {
86                0.0
87            };
88
89        let y_axis_width = theme.axis_ticks_length
90            + if theme.axis_text_y.visible {
91                y_label_width.unwrap_or(theme.axis_text_y.size * 3.5 + 4.0)
92            } else {
93                0.0
94            }
95            + if theme.axis_title_y.visible {
96                theme.axis_title_y.size + 8.0
97            } else {
98                0.0
99            };
100
101        let legend_size = if has_legend {
102            theme.legend_margin.left
103                + theme.legend_key_width
104                + theme.legend_spacing
105                + theme.legend_text.size * 6.0
106                + theme.legend_margin.right
107        } else {
108            0.0
109        };
110
111        // A horizontal (top/bottom) legend only needs ONE row of height, not the
112        // width-based `legend_size` — otherwise a big gap opens above the plot.
113        let legend_band = if has_legend {
114            theme.legend_margin.top
115                + theme.legend_key_height
116                + theme.legend_spacing
117                + theme.legend_margin.bottom
118        } else {
119            0.0
120        };
121        // Determine legend space allocation per position
122        let (legend_right, legend_left, legend_top, legend_bottom) = if has_legend {
123            match theme.legend_position {
124                LegendPosition::Right => (legend_size, 0.0, 0.0, 0.0),
125                LegendPosition::Left => (0.0, legend_size, 0.0, 0.0),
126                LegendPosition::Top => (0.0, 0.0, legend_band, 0.0),
127                LegendPosition::Bottom => (0.0, 0.0, 0.0, legend_band),
128                // Inside/None overlay the panel, reserving no external space.
129                LegendPosition::None | LegendPosition::Inside(..) => (0.0, 0.0, 0.0, 0.0),
130            }
131        } else {
132            (0.0, 0.0, 0.0, 0.0)
133        };
134
135        let plot_x = margin.left + y_axis_width + legend_left;
136        // A top x-axis reserves its space above the panel instead of below.
137        let plot_y = margin.top
138            + title_height
139            + subtitle_height
140            + legend_top
141            + if x_axis_top { x_axis_height } else { 0.0 };
142        let plot_width =
143            width - margin.left - margin.right - y_axis_width - legend_right - legend_left;
144        let plot_height = height
145            - margin.top
146            - margin.bottom
147            - title_height
148            - subtitle_height
149            - caption_height
150            - x_axis_height
151            - legend_top
152            - legend_bottom;
153
154        let mut plot_width = plot_width.max(50.0);
155        let mut plot_height = plot_height.max(50.0);
156        let mut plot_x = plot_x;
157        let mut plot_y = plot_y;
158
159        // Fix the panel aspect ratio (R's `aspect.ratio` = height/width),
160        // shrinking the larger dimension and centering within the available box.
161        if let Some(r) = theme.aspect_ratio {
162            if r > 0.0 {
163                let (avail_w, avail_h) = (plot_width, plot_height);
164                if avail_w * r <= avail_h {
165                    plot_height = avail_w * r;
166                    plot_y += (avail_h - plot_height) / 2.0;
167                } else {
168                    plot_width = avail_h / r;
169                    plot_x += (avail_w - plot_width) / 2.0;
170                }
171            }
172        }
173
174        PlotLayout {
175            total: Rect {
176                x: 0.0,
177                y: 0.0,
178                width,
179                height,
180            },
181            plot_area: Rect {
182                x: plot_x,
183                y: plot_y,
184                width: plot_width,
185                height: plot_height,
186            },
187            title_area: Rect {
188                x: plot_x,
189                y: margin.top,
190                width: plot_width,
191                height: title_height,
192            },
193            subtitle_area: Rect {
194                x: plot_x,
195                y: margin.top + title_height,
196                width: plot_width,
197                height: subtitle_height,
198            },
199            caption_area: Rect {
200                x: plot_x,
201                y: plot_y + plot_height + x_axis_height,
202                width: plot_width,
203                height: caption_height,
204            },
205            x_axis_area: Rect {
206                x: plot_x,
207                y: plot_y + plot_height,
208                width: plot_width,
209                height: x_axis_height,
210            },
211            y_axis_area: Rect {
212                x: margin.left + legend_left,
213                y: plot_y,
214                width: y_axis_width,
215                height: plot_height,
216            },
217            legend_area: Rect {
218                x: plot_x + plot_width,
219                y: plot_y,
220                width: legend_size,
221                height: plot_height,
222            },
223        }
224    }
225}