Skip to main content

esoc_chart/
legend.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Legend rendering for chart series.
3
4use esoc_gfx::canvas::Canvas;
5use esoc_gfx::color::Color;
6use esoc_gfx::element::{DrawElement, Element};
7use esoc_gfx::geom::Rect;
8use esoc_gfx::layer::Layer;
9use esoc_gfx::style::{Fill, FontStyle, Stroke, TextAnchor};
10
11use crate::theme::Theme;
12
13/// Legend position relative to the plot area.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum LegendPosition {
16    /// Upper-right corner (default).
17    #[default]
18    UpperRight,
19    /// Upper-left corner.
20    UpperLeft,
21    /// Lower-right corner.
22    LowerRight,
23    /// Lower-left corner.
24    LowerLeft,
25}
26
27/// A single legend entry.
28#[derive(Clone, Debug)]
29pub struct LegendEntry {
30    /// Series label.
31    pub label: String,
32    /// Series color.
33    pub color: Color,
34}
35
36/// Render a legend onto a canvas.
37pub fn render_legend(
38    canvas: &mut Canvas,
39    plot_area: Rect,
40    entries: &[LegendEntry],
41    position: LegendPosition,
42    theme: &Theme,
43) {
44    if entries.is_empty() {
45        return;
46    }
47
48    let row_height = theme.legend_font_size * 1.6;
49    let swatch_size = theme.legend_font_size * 0.8;
50    let padding = 8.0;
51    let gap = 6.0;
52
53    // Estimate legend box size
54    let text_measurer = esoc_gfx::text::HeuristicTextMeasurer;
55    let max_label_width = entries
56        .iter()
57        .map(|e| {
58            esoc_gfx::text::TextMeasurer::measure_width(
59                &text_measurer,
60                &e.label,
61                theme.legend_font_size,
62            )
63        })
64        .fold(0.0_f64, f64::max);
65
66    let box_width = padding * 2.0 + swatch_size + gap + max_label_width;
67    let box_height = padding * 2.0 + entries.len() as f64 * row_height;
68
69    // Position the legend box
70    let (bx, by) = match position {
71        LegendPosition::UpperRight => (plot_area.right() - box_width - 10.0, plot_area.y + 10.0),
72        LegendPosition::UpperLeft => (plot_area.x + 10.0, plot_area.y + 10.0),
73        LegendPosition::LowerRight => (
74            plot_area.right() - box_width - 10.0,
75            plot_area.bottom() - box_height - 10.0,
76        ),
77        LegendPosition::LowerLeft => (plot_area.x + 10.0, plot_area.bottom() - box_height - 10.0),
78    };
79
80    // Background box
81    canvas.add(DrawElement::new(
82        Element::Rect {
83            rect: Rect::new(bx, by, box_width, box_height),
84            fill: Fill::Solid(theme.background.with_alpha(0.9)),
85            stroke: Some(Stroke::solid(theme.grid_color, 0.5)),
86            rx: 3.0,
87        },
88        Layer::Legend,
89    ));
90
91    // Entries
92    for (i, entry) in entries.iter().enumerate() {
93        let ey = by + padding + i as f64 * row_height + row_height / 2.0;
94
95        // Color swatch
96        canvas.add(DrawElement::new(
97            Element::Rect {
98                rect: Rect::new(
99                    bx + padding,
100                    ey - swatch_size / 2.0,
101                    swatch_size,
102                    swatch_size,
103                ),
104                fill: Fill::Solid(entry.color),
105                stroke: None,
106                rx: 2.0,
107            },
108            Layer::Legend,
109        ));
110
111        // Label
112        let font = FontStyle {
113            family: theme.font_family.clone(),
114            size: theme.legend_font_size,
115            weight: 400,
116            color: theme.foreground,
117            anchor: TextAnchor::Start,
118        };
119        canvas.add(DrawElement::new(
120            Element::text(
121                bx + padding + swatch_size + gap,
122                ey + theme.legend_font_size * 0.35,
123                &entry.label,
124                font,
125            ),
126            Layer::Legend,
127        ));
128    }
129}