Skip to main content

chartml_core/layout/
legend.rs

1use crate::element::{ChartElement, TextAnchor};
2
3/// Legend symbol type — matches the JS renderSymbol() function.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum LegendMark {
6    Rect,    // bar, area, pie — rounded rectangle
7    Line,    // line charts — horizontal line
8    Circle,  // scatter, bubble — circle
9}
10
11/// A positioned legend item.
12#[derive(Debug, Clone)]
13pub struct LegendItem {
14    pub index: usize,
15    pub label: String,
16    pub color: String,
17    pub x: f64,
18    pub y: f64,
19    pub width: f64,
20    pub row: usize,
21    pub visible: bool,
22}
23
24/// Legend layout configuration.
25pub struct LegendConfig {
26    pub symbol_size: f64,
27    pub symbol_text_gap: f64,
28    pub item_padding: f64,
29    pub row_height: f64,
30    pub max_rows: usize,
31    pub max_label_chars: usize,
32    pub alignment: LegendAlignment,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum LegendAlignment {
37    Left,
38    Center,
39    Right,
40}
41
42impl Default for LegendConfig {
43    fn default() -> Self {
44        Self {
45            symbol_size: 12.0,
46            symbol_text_gap: 6.0,
47            item_padding: 12.0,
48            row_height: 20.0,
49            max_rows: 3,
50            max_label_chars: 20,
51            alignment: LegendAlignment::Center,
52        }
53    }
54}
55
56/// Result of legend layout computation.
57#[derive(Debug, Clone)]
58pub struct LegendLayoutResult {
59    pub items: Vec<LegendItem>,
60    pub total_height: f64,
61    pub overflow_count: usize,
62}
63
64/// Compute legend layout for the given labels and colors.
65///
66/// Algorithm (matches JS legendUtils.js):
67/// 1. Calculate each item width: symbol_size + symbol_text_gap + text_width + item_padding
68/// 2. Fit items into rows left-to-right
69/// 3. Start new row when item doesn't fit and we haven't exceeded max_rows
70/// 4. Mark items beyond max_rows as not visible
71/// 5. Apply alignment offset to each row
72pub fn calculate_legend_layout(
73    labels: &[String],
74    colors: &[String],
75    available_width: f64,
76    config: &LegendConfig,
77) -> LegendLayoutResult {
78    use super::labels::approximate_text_width;
79
80    let mut items = Vec::with_capacity(labels.len());
81    let mut current_x = 0.0;
82    let mut current_row = 0_usize;
83    let mut overflow_count = 0;
84
85    // Track rows for alignment: (start_index, end_index, row_width)
86    let mut rows: Vec<(usize, usize, f64)> = vec![(0, 0, 0.0)];
87
88    for (i, label) in labels.iter().enumerate() {
89        // First, compute full-label width to decide row placement
90        let full_text_width = approximate_text_width(label);
91        let full_item_width = config.symbol_size + config.symbol_text_gap + full_text_width + config.item_padding;
92
93        // Check if full item fits on current row; if not, wrap to next row
94        if current_x + full_item_width > available_width && current_x > 0.0 {
95            current_row += 1;
96            current_x = 0.0;
97            if current_row < config.max_rows {
98                rows.push((i, i, 0.0));
99            }
100        }
101
102        // Now truncate only if the label doesn't fit in the remaining row width
103        let remaining_width = available_width - current_x;
104        let non_text_width = config.symbol_size + config.symbol_text_gap + config.item_padding;
105        let max_text_width = remaining_width - non_text_width;
106
107        let display_label = if full_text_width > max_text_width {
108            // Progressively truncate until it fits
109            let char_count = label.chars().count();
110            let mut truncated_count = char_count.min(config.max_label_chars);
111            loop {
112                if truncated_count == 0 {
113                    break "\u{2026}".to_string();
114                }
115                let candidate: String = label.chars().take(truncated_count).collect();
116                let candidate_with_ellipsis = format!("{}\u{2026}", candidate);
117                if approximate_text_width(&candidate_with_ellipsis) <= max_text_width {
118                    break candidate_with_ellipsis;
119                }
120                truncated_count -= 1;
121            }
122        } else {
123            label.clone()
124        };
125
126        let text_width = approximate_text_width(&display_label);
127        let item_width = config.symbol_size + config.symbol_text_gap + text_width + config.item_padding;
128
129        let visible = current_row < config.max_rows;
130        if !visible {
131            overflow_count += 1;
132        }
133
134        let y = current_row as f64 * config.row_height;
135
136        items.push(LegendItem {
137            index: i,
138            label: display_label,
139            color: colors.get(i).cloned().unwrap_or_default(),
140            x: current_x,
141            y,
142            width: item_width,
143            row: current_row,
144            visible,
145        });
146
147        if visible {
148            if let Some(row) = rows.last_mut() {
149                row.1 = i;
150                row.2 = current_x + item_width;
151            }
152        }
153
154        current_x += item_width;
155    }
156
157    // Apply alignment offset
158    if config.alignment != LegendAlignment::Left {
159        for &(start, end, row_width) in &rows {
160            let offset = match config.alignment {
161                LegendAlignment::Center => (available_width - row_width) / 2.0,
162                LegendAlignment::Right => available_width - row_width,
163                LegendAlignment::Left => 0.0,
164            };
165            if offset > 0.0 {
166                for item in items.iter_mut() {
167                    if item.index >= start && item.index <= end && item.visible {
168                        item.x += offset;
169                    }
170                }
171            }
172        }
173    }
174
175    let total_rows = (current_row + 1).min(config.max_rows);
176    let total_height = total_rows as f64 * config.row_height;
177
178    LegendLayoutResult {
179        items,
180        total_height,
181        overflow_count,
182    }
183}
184
185/// Generate legend SVG elements for the given series.
186/// Computes layout, then renders symbol + label elements for each visible item.
187pub fn generate_legend_elements(
188    series_names: &[String],
189    colors: &[String],
190    chart_width: f64,
191    y_position: f64,
192    mark: LegendMark,
193) -> Vec<ChartElement> {
194    if series_names.len() <= 1 {
195        return Vec::new();
196    }
197
198    let config = LegendConfig::default();
199    let result = calculate_legend_layout(series_names, colors, chart_width, &config);
200
201    let mut elements = Vec::new();
202
203    for item in &result.items {
204        if !item.visible {
205            continue;
206        }
207
208        let x = item.x;
209        let sym_y = y_position + item.y;
210
211        match mark {
212            LegendMark::Line => {
213                elements.push(ChartElement::Line {
214                    x1: x,
215                    y1: sym_y + config.symbol_size / 2.0,
216                    x2: x + config.symbol_size,
217                    y2: sym_y + config.symbol_size / 2.0,
218                    stroke: item.color.clone(),
219                    stroke_width: Some(2.5),
220                    stroke_dasharray: None,
221                    class: "legend-symbol legend-line".to_string(),
222                });
223            }
224            LegendMark::Circle => {
225                elements.push(ChartElement::Circle {
226                    cx: x + config.symbol_size / 2.0,
227                    cy: sym_y + config.symbol_size / 2.0,
228                    r: config.symbol_size / 2.0 - 1.0,
229                    fill: item.color.clone(),
230                    stroke: None,
231                    class: "legend-symbol legend-circle".to_string(),
232                    data: None,
233                });
234            }
235            LegendMark::Rect => {
236                elements.push(ChartElement::Rect {
237                    x,
238                    y: sym_y,
239                    width: config.symbol_size,
240                    height: config.symbol_size,
241                    fill: item.color.clone(),
242                    stroke: None,
243                    class: "legend-symbol".to_string(),
244                    data: None,
245                });
246            }
247        }
248
249        elements.push(ChartElement::Text {
250            x: x + config.symbol_size + config.symbol_text_gap,
251            y: sym_y + 10.0,
252            content: item.label.clone(),
253            anchor: TextAnchor::Start,
254            dominant_baseline: None,
255            transform: None,
256            font_size: Some("11px".to_string()),
257            font_weight: None,
258            fill: Some("#333".to_string()),
259            class: "legend-label".to_string(),
260            data: None,
261        });
262    }
263
264    elements
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn make_labels(names: &[&str]) -> Vec<String> {
272        names.iter().map(|s| s.to_string()).collect()
273    }
274
275    fn make_colors(n: usize) -> Vec<String> {
276        (0..n).map(|i| format!("#{:02x}{:02x}{:02x}", i * 30, i * 50, i * 70)).collect()
277    }
278
279    #[test]
280    fn legend_single_row() {
281        let labels = make_labels(&["Alpha", "Beta", "Gamma"]);
282        let colors = make_colors(3);
283        let config = LegendConfig {
284            alignment: LegendAlignment::Left,
285            ..Default::default()
286        };
287        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
288        assert_eq!(result.items.len(), 3);
289        assert_eq!(result.overflow_count, 0);
290        // All items should be on row 0
291        for item in &result.items {
292            assert_eq!(item.row, 0);
293            assert!(item.visible);
294        }
295        assert!((result.total_height - 20.0).abs() < f64::EPSILON);
296    }
297
298    #[test]
299    fn legend_multi_row() {
300        // Use many items with a narrow available width to force wrapping
301        let labels = make_labels(&["Series One", "Series Two", "Series Three", "Series Four", "Series Five"]);
302        let colors = make_colors(5);
303        let config = LegendConfig {
304            alignment: LegendAlignment::Left,
305            ..Default::default()
306        };
307        // Narrow width: each item is ~(12 + 6 + 70 + 12) = ~100px for "Series One" (10 chars * 7 = 70)
308        let result = calculate_legend_layout(&labels, &colors, 220.0, &config);
309        assert!(result.items.iter().any(|item| item.row > 0),
310            "Expected items to wrap to multiple rows");
311        assert_eq!(result.overflow_count, 0); // 5 items should fit in 3 rows
312    }
313
314    #[test]
315    fn legend_overflow() {
316        // Many items, very narrow width, only 1 row allowed
317        let labels = make_labels(&["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]);
318        let colors = make_colors(10);
319        let config = LegendConfig {
320            max_rows: 1,
321            alignment: LegendAlignment::Left,
322            ..Default::default()
323        };
324        // Each item ~(12+6+7+12)=37px, 10 items = 370px needed, only 100 available
325        let result = calculate_legend_layout(&labels, &colors, 100.0, &config);
326        assert!(result.overflow_count > 0,
327            "Expected overflow, got 0 overflow items");
328    }
329
330    #[test]
331    fn legend_empty() {
332        let labels: Vec<String> = vec![];
333        let colors: Vec<String> = vec![];
334        let config = LegendConfig::default();
335        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
336        assert!(result.items.is_empty());
337        assert_eq!(result.overflow_count, 0);
338    }
339
340    #[test]
341    fn legend_center_alignment() {
342        let labels = make_labels(&["A", "B"]);
343        let colors = make_colors(2);
344        let config = LegendConfig {
345            alignment: LegendAlignment::Center,
346            ..Default::default()
347        };
348        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
349        // With center alignment and wide available width, first item x should be > 0
350        assert!(result.items[0].x > 0.0,
351            "Expected first item to be offset for centering, got x={}", result.items[0].x);
352    }
353
354    #[test]
355    fn legend_label_truncation() {
356        let labels = vec!["This is a very long label that exceeds the maximum".to_string()];
357        let colors = make_colors(1);
358        let config = LegendConfig {
359            alignment: LegendAlignment::Left,
360            ..Default::default()
361        };
362        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
363        assert!(result.items[0].label.ends_with('\u{2026}'),
364            "Expected truncated label with ellipsis, got '{}'", result.items[0].label);
365        assert!(result.items[0].label.chars().count() <= config.max_label_chars,
366            "Truncated label should be at most {} chars", config.max_label_chars);
367    }
368
369    #[test]
370    fn legend_colors_assigned() {
371        let labels = make_labels(&["A", "B", "C"]);
372        let colors = vec!["red".to_string(), "green".to_string(), "blue".to_string()];
373        let config = LegendConfig {
374            alignment: LegendAlignment::Left,
375            ..Default::default()
376        };
377        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
378        assert_eq!(result.items[0].color, "red");
379        assert_eq!(result.items[1].color, "green");
380        assert_eq!(result.items[2].color, "blue");
381    }
382}