embedded_charts/legend/
position.rs

1//! Legend positioning and layout calculation.
2
3use crate::error::ChartResult;
4use embedded_graphics::{prelude::*, primitives::Rectangle};
5
6/// Legend position options
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum LegendPosition {
9    /// Top of the chart
10    Top,
11    /// Bottom of the chart
12    Bottom,
13    /// Left side of the chart
14    Left,
15    /// Right side of the chart
16    Right,
17    /// Top-left corner
18    TopLeft,
19    /// Top-right corner
20    TopRight,
21    /// Bottom-left corner
22    BottomLeft,
23    /// Bottom-right corner
24    BottomRight,
25    /// Custom position with specific coordinates
26    Custom(Point),
27    /// Floating position (overlays the chart)
28    Floating(Point),
29}
30
31/// Legend alignment within its position
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LegendAlignment {
34    /// Start alignment (left for horizontal, top for vertical)
35    Start,
36    /// Center alignment
37    Center,
38    /// End alignment (right for horizontal, bottom for vertical)
39    End,
40}
41
42/// Margins around the legend
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct LegendMargins {
45    /// Top margin
46    pub top: u32,
47    /// Right margin
48    pub right: u32,
49    /// Bottom margin
50    pub bottom: u32,
51    /// Left margin
52    pub left: u32,
53}
54
55/// Position calculator for legend layout
56#[derive(Debug, Clone)]
57pub struct PositionCalculator {
58    /// Chart area (total available space)
59    chart_area: Rectangle,
60    /// Plot area (area for actual chart content)
61    plot_area: Rectangle,
62    /// Legend margins
63    margins: LegendMargins,
64    /// Legend alignment
65    alignment: LegendAlignment,
66}
67
68impl PositionCalculator {
69    /// Create a new position calculator
70    pub fn new(chart_area: Rectangle, plot_area: Rectangle) -> Self {
71        Self {
72            chart_area,
73            plot_area,
74            margins: LegendMargins::default(),
75            alignment: LegendAlignment::Start,
76        }
77    }
78
79    /// Set legend margins
80    pub fn with_margins(mut self, margins: LegendMargins) -> Self {
81        self.margins = margins;
82        self
83    }
84
85    /// Set legend alignment
86    pub fn with_alignment(mut self, alignment: LegendAlignment) -> Self {
87        self.alignment = alignment;
88        self
89    }
90
91    /// Calculate the legend rectangle for a given position and size
92    pub fn calculate_legend_rect(
93        &self,
94        position: LegendPosition,
95        legend_size: Size,
96    ) -> ChartResult<Rectangle> {
97        match position {
98            LegendPosition::Top => self.calculate_top_position(legend_size),
99            LegendPosition::Bottom => self.calculate_bottom_position(legend_size),
100            LegendPosition::Left => self.calculate_left_position(legend_size),
101            LegendPosition::Right => self.calculate_right_position(legend_size),
102            LegendPosition::TopLeft => self.calculate_corner_position(legend_size, true, true),
103            LegendPosition::TopRight => self.calculate_corner_position(legend_size, true, false),
104            LegendPosition::BottomLeft => self.calculate_corner_position(legend_size, false, true),
105            LegendPosition::BottomRight => {
106                self.calculate_corner_position(legend_size, false, false)
107            }
108            LegendPosition::Custom(point) => Ok(Rectangle::new(point, legend_size)),
109            LegendPosition::Floating(point) => Ok(Rectangle::new(point, legend_size)),
110        }
111    }
112
113    /// Calculate the adjusted plot area when legend is positioned outside the chart
114    pub fn calculate_adjusted_plot_area(
115        &self,
116        position: LegendPosition,
117        legend_size: Size,
118    ) -> ChartResult<Rectangle> {
119        match position {
120            LegendPosition::Top => {
121                let height_reduction = legend_size.height + self.margins.vertical();
122                Ok(Rectangle::new(
123                    Point::new(
124                        self.plot_area.top_left.x,
125                        self.plot_area.top_left.y + height_reduction as i32,
126                    ),
127                    Size::new(
128                        self.plot_area.size.width,
129                        self.plot_area.size.height.saturating_sub(height_reduction),
130                    ),
131                ))
132            }
133            LegendPosition::Bottom => {
134                let height_reduction = legend_size.height + self.margins.vertical();
135                Ok(Rectangle::new(
136                    self.plot_area.top_left,
137                    Size::new(
138                        self.plot_area.size.width,
139                        self.plot_area.size.height.saturating_sub(height_reduction),
140                    ),
141                ))
142            }
143            LegendPosition::Left => {
144                let width_reduction = legend_size.width + self.margins.horizontal();
145                Ok(Rectangle::new(
146                    Point::new(
147                        self.plot_area.top_left.x + width_reduction as i32,
148                        self.plot_area.top_left.y,
149                    ),
150                    Size::new(
151                        self.plot_area.size.width.saturating_sub(width_reduction),
152                        self.plot_area.size.height,
153                    ),
154                ))
155            }
156            LegendPosition::Right => {
157                let width_reduction = legend_size.width + self.margins.horizontal();
158                Ok(Rectangle::new(
159                    self.plot_area.top_left,
160                    Size::new(
161                        self.plot_area.size.width.saturating_sub(width_reduction),
162                        self.plot_area.size.height,
163                    ),
164                ))
165            }
166            // Corner and floating positions don't affect plot area
167            _ => Ok(self.plot_area),
168        }
169    }
170
171    /// Check if the legend fits within the available space
172    pub fn validate_legend_fit(
173        &self,
174        position: LegendPosition,
175        legend_size: Size,
176    ) -> ChartResult<bool> {
177        let legend_rect = self.calculate_legend_rect(position, legend_size)?;
178
179        // For legends positioned outside the plot area (like Right position),
180        // we need to check if they fit within a reasonable extended area
181        // For all positions, check if legend rectangle is within chart area
182        let fits_horizontally = legend_rect.top_left.x >= self.chart_area.top_left.x
183            && legend_rect.top_left.x + legend_size.width as i32
184                <= self.chart_area.top_left.x + self.chart_area.size.width as i32;
185
186        let fits_vertically = legend_rect.top_left.y >= self.chart_area.top_left.y
187            && legend_rect.top_left.y + legend_size.height as i32
188                <= self.chart_area.top_left.y + self.chart_area.size.height as i32;
189
190        Ok(fits_horizontally && fits_vertically)
191    }
192
193    // Private helper methods
194
195    fn calculate_top_position(&self, legend_size: Size) -> ChartResult<Rectangle> {
196        let x = match self.alignment {
197            LegendAlignment::Start => self.chart_area.top_left.x + self.margins.left as i32,
198            LegendAlignment::Center => {
199                self.chart_area.top_left.x
200                    + (self.chart_area.size.width as i32 - legend_size.width as i32) / 2
201            }
202            LegendAlignment::End => {
203                self.chart_area.top_left.x + self.chart_area.size.width as i32
204                    - legend_size.width as i32
205                    - self.margins.right as i32
206            }
207        };
208
209        let y = self.chart_area.top_left.y + self.margins.top as i32;
210
211        Ok(Rectangle::new(Point::new(x, y), legend_size))
212    }
213
214    fn calculate_bottom_position(&self, legend_size: Size) -> ChartResult<Rectangle> {
215        let x = match self.alignment {
216            LegendAlignment::Start => self.chart_area.top_left.x + self.margins.left as i32,
217            LegendAlignment::Center => {
218                self.chart_area.top_left.x
219                    + (self.chart_area.size.width as i32 - legend_size.width as i32) / 2
220            }
221            LegendAlignment::End => {
222                self.chart_area.top_left.x + self.chart_area.size.width as i32
223                    - legend_size.width as i32
224                    - self.margins.right as i32
225            }
226        };
227
228        let y = self.chart_area.top_left.y + self.chart_area.size.height as i32
229            - legend_size.height as i32
230            - self.margins.bottom as i32;
231
232        Ok(Rectangle::new(Point::new(x, y), legend_size))
233    }
234
235    fn calculate_left_position(&self, legend_size: Size) -> ChartResult<Rectangle> {
236        let x = self.chart_area.top_left.x + self.margins.left as i32;
237
238        let y = match self.alignment {
239            LegendAlignment::Start => self.chart_area.top_left.y + self.margins.top as i32,
240            LegendAlignment::Center => {
241                self.chart_area.top_left.y
242                    + (self.chart_area.size.height as i32 - legend_size.height as i32) / 2
243            }
244            LegendAlignment::End => {
245                self.chart_area.top_left.y + self.chart_area.size.height as i32
246                    - legend_size.height as i32
247                    - self.margins.bottom as i32
248            }
249        };
250
251        Ok(Rectangle::new(Point::new(x, y), legend_size))
252    }
253
254    fn calculate_right_position(&self, legend_size: Size) -> ChartResult<Rectangle> {
255        // Position legend on the right side within the chart area bounds
256        let x = self.chart_area.top_left.x + self.chart_area.size.width as i32
257            - legend_size.width as i32
258            - self.margins.right as i32;
259
260        let y = match self.alignment {
261            LegendAlignment::Start => self.chart_area.top_left.y + self.margins.top as i32,
262            LegendAlignment::Center => {
263                self.chart_area.top_left.y
264                    + (self.chart_area.size.height as i32 - legend_size.height as i32) / 2
265            }
266            LegendAlignment::End => {
267                self.chart_area.top_left.y + self.chart_area.size.height as i32
268                    - legend_size.height as i32
269                    - self.margins.bottom as i32
270            }
271        };
272
273        Ok(Rectangle::new(Point::new(x, y), legend_size))
274    }
275
276    fn calculate_corner_position(
277        &self,
278        legend_size: Size,
279        top: bool,
280        left: bool,
281    ) -> ChartResult<Rectangle> {
282        let x = if left {
283            self.chart_area.top_left.x + self.margins.left as i32
284        } else {
285            self.chart_area.top_left.x + self.chart_area.size.width as i32
286                - legend_size.width as i32
287                - self.margins.right as i32
288        };
289
290        let y = if top {
291            self.chart_area.top_left.y + self.margins.top as i32
292        } else {
293            self.chart_area.top_left.y + self.chart_area.size.height as i32
294                - legend_size.height as i32
295                - self.margins.bottom as i32
296        };
297
298        Ok(Rectangle::new(Point::new(x, y), legend_size))
299    }
300}
301
302impl LegendMargins {
303    /// Create uniform margins
304    pub const fn all(value: u32) -> Self {
305        Self {
306            top: value,
307            right: value,
308            bottom: value,
309            left: value,
310        }
311    }
312
313    /// Create symmetric margins
314    pub const fn symmetric(horizontal: u32, vertical: u32) -> Self {
315        Self {
316            top: vertical,
317            right: horizontal,
318            bottom: vertical,
319            left: horizontal,
320        }
321    }
322
323    /// Create custom margins
324    pub const fn new(top: u32, right: u32, bottom: u32, left: u32) -> Self {
325        Self {
326            top,
327            right,
328            bottom,
329            left,
330        }
331    }
332
333    /// Get total horizontal margins
334    pub const fn horizontal(&self) -> u32 {
335        self.left + self.right
336    }
337
338    /// Get total vertical margins
339    pub const fn vertical(&self) -> u32 {
340        self.top + self.bottom
341    }
342}
343
344impl Default for LegendPosition {
345    fn default() -> Self {
346        Self::Right
347    }
348}
349
350impl Default for LegendAlignment {
351    fn default() -> Self {
352        Self::Start
353    }
354}
355
356impl Default for LegendMargins {
357    fn default() -> Self {
358        Self::all(8)
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_legend_margins() {
368        let margins = LegendMargins::all(10);
369        assert_eq!(margins.horizontal(), 20);
370        assert_eq!(margins.vertical(), 20);
371
372        let margins = LegendMargins::symmetric(5, 8);
373        assert_eq!(margins.horizontal(), 10);
374        assert_eq!(margins.vertical(), 16);
375    }
376
377    #[test]
378    fn test_position_calculator() {
379        let chart_area = Rectangle::new(Point::zero(), Size::new(200, 150));
380        let plot_area = Rectangle::new(Point::new(20, 20), Size::new(160, 110));
381        let calculator = PositionCalculator::new(chart_area, plot_area);
382
383        let legend_size = Size::new(60, 40);
384
385        // Test right position
386        let legend_rect = calculator
387            .calculate_legend_rect(LegendPosition::Right, legend_size)
388            .unwrap();
389        // Legend should be positioned within chart area, on the right side
390        assert!(
391            legend_rect.top_left.x + legend_size.width as i32
392                <= chart_area.top_left.x + chart_area.size.width as i32
393        );
394        assert!(legend_rect.top_left.x >= chart_area.top_left.x);
395
396        // Test that legend fits
397        assert!(calculator
398            .validate_legend_fit(LegendPosition::Right, legend_size)
399            .unwrap());
400    }
401
402    #[test]
403    fn test_adjusted_plot_area() {
404        let chart_area = Rectangle::new(Point::zero(), Size::new(200, 150));
405        let plot_area = Rectangle::new(Point::new(20, 20), Size::new(160, 110));
406        let calculator = PositionCalculator::new(chart_area, plot_area);
407
408        let legend_size = Size::new(60, 40);
409
410        // Test right position adjustment
411        let adjusted = calculator
412            .calculate_adjusted_plot_area(LegendPosition::Right, legend_size)
413            .unwrap();
414        assert!(adjusted.size.width < plot_area.size.width);
415
416        // Test bottom position adjustment
417        let adjusted = calculator
418            .calculate_adjusted_plot_area(LegendPosition::Bottom, legend_size)
419            .unwrap();
420        assert!(adjusted.size.height < plot_area.size.height);
421    }
422}