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