Skip to main content

chartml_core/layout/
legend.rs

1use crate::element::{ChartElement, TextAnchor, TextRole, TextStyle};
2use crate::layout::labels::{measure_text, TextMetrics};
3
4/// Legend symbol type — matches the JS renderSymbol() function.
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum LegendMark {
7    Rect,    // bar, area, pie — rounded rectangle
8    Line,    // line charts — horizontal line
9    Circle,  // scatter, bubble — circle
10}
11
12/// A positioned legend item.
13#[derive(Debug, Clone)]
14pub struct LegendItem {
15    pub index: usize,
16    pub label: String,
17    pub color: String,
18    pub x: f64,
19    pub y: f64,
20    pub width: f64,
21    pub row: usize,
22    pub visible: bool,
23}
24
25/// Legend layout configuration.
26pub struct LegendConfig {
27    pub symbol_size: f64,
28    pub symbol_text_gap: f64,
29    pub item_padding: f64,
30    pub row_height: f64,
31    pub max_rows: usize,
32    pub max_label_chars: usize,
33    pub alignment: LegendAlignment,
34    /// Text shaping metrics for legend labels. Defaults to the legacy
35    /// calibration, so existing callers get byte-identical layout.
36    pub text_metrics: TextMetrics,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum LegendAlignment {
41    Left,
42    Center,
43    Right,
44}
45
46impl Default for LegendConfig {
47    fn default() -> Self {
48        Self {
49            symbol_size: 12.0,
50            symbol_text_gap: 6.0,
51            item_padding: 12.0,
52            row_height: 20.0,
53            max_rows: 3,
54            max_label_chars: 20,
55            alignment: LegendAlignment::Center,
56            text_metrics: TextMetrics::default(),
57        }
58    }
59}
60
61/// Result of legend layout computation.
62#[derive(Debug, Clone)]
63pub struct LegendLayoutResult {
64    pub items: Vec<LegendItem>,
65    pub total_height: f64,
66    pub overflow_count: usize,
67}
68
69/// Compute legend layout for the given labels and colors.
70///
71/// Algorithm (matches JS legendUtils.js):
72/// 1. Calculate each item width: symbol_size + symbol_text_gap + text_width + item_padding
73/// 2. Fit items into rows left-to-right
74/// 3. Start new row when item doesn't fit and we haven't exceeded max_rows
75/// 4. Mark items beyond max_rows as not visible
76/// 5. Apply alignment offset to each row
77pub fn calculate_legend_layout(
78    labels: &[String],
79    colors: &[String],
80    available_width: f64,
81    config: &LegendConfig,
82) -> LegendLayoutResult {
83    let metrics = &config.text_metrics;
84    let measure = |s: &str| measure_text(s, metrics);
85
86    let mut items = Vec::with_capacity(labels.len());
87    let mut current_x = 0.0;
88    let mut current_row = 0_usize;
89    let mut overflow_count = 0;
90
91    // Track rows for alignment: (start_index, end_index, row_width)
92    let mut rows: Vec<(usize, usize, f64)> = vec![(0, 0, 0.0)];
93
94    for (i, label) in labels.iter().enumerate() {
95        // First, compute full-label width to decide row placement
96        let full_text_width = measure(label);
97        let full_item_width = config.symbol_size + config.symbol_text_gap + full_text_width + config.item_padding;
98
99        // Check if full item fits on current row; if not, wrap to next row
100        if current_x + full_item_width > available_width && current_x > 0.0 {
101            current_row += 1;
102            current_x = 0.0;
103            if current_row < config.max_rows {
104                rows.push((i, i, 0.0));
105            }
106        }
107
108        // Now truncate only if the label doesn't fit in the remaining row width
109        let remaining_width = available_width - current_x;
110        let non_text_width = config.symbol_size + config.symbol_text_gap + config.item_padding;
111        let max_text_width = remaining_width - non_text_width;
112
113        let display_label = if full_text_width > max_text_width {
114            // Progressively truncate until it fits
115            let char_count = label.chars().count();
116            let mut truncated_count = char_count.min(config.max_label_chars);
117            loop {
118                if truncated_count == 0 {
119                    break "\u{2026}".to_string();
120                }
121                let candidate: String = label.chars().take(truncated_count).collect();
122                let candidate_with_ellipsis = format!("{}\u{2026}", candidate);
123                if measure(&candidate_with_ellipsis) <= max_text_width {
124                    break candidate_with_ellipsis;
125                }
126                truncated_count -= 1;
127            }
128        } else {
129            label.clone()
130        };
131
132        let text_width = measure(&display_label);
133        let item_width = config.symbol_size + config.symbol_text_gap + text_width + config.item_padding;
134
135        let visible = current_row < config.max_rows;
136        if !visible {
137            overflow_count += 1;
138        }
139
140        let y = current_row as f64 * config.row_height;
141
142        items.push(LegendItem {
143            index: i,
144            label: display_label,
145            color: colors.get(i).cloned().unwrap_or_default(),
146            x: current_x,
147            y,
148            width: item_width,
149            row: current_row,
150            visible,
151        });
152
153        if visible {
154            if let Some(row) = rows.last_mut() {
155                row.1 = i;
156                row.2 = current_x + item_width;
157            }
158        }
159
160        current_x += item_width;
161    }
162
163    // Apply alignment offset
164    if config.alignment != LegendAlignment::Left {
165        for &(start, end, row_width) in &rows {
166            let offset = match config.alignment {
167                LegendAlignment::Center => (available_width - row_width) / 2.0,
168                LegendAlignment::Right => available_width - row_width,
169                LegendAlignment::Left => 0.0,
170            };
171            if offset > 0.0 {
172                for item in items.iter_mut() {
173                    if item.index >= start && item.index <= end && item.visible {
174                        item.x += offset;
175                    }
176                }
177            }
178        }
179    }
180
181    let total_rows = (current_row + 1).min(config.max_rows);
182    let total_height = total_rows as f64 * config.row_height;
183
184    LegendLayoutResult {
185        items,
186        total_height,
187        overflow_count,
188    }
189}
190
191/// Generate legend SVG elements for the given series.
192/// Computes layout, then renders symbol + label elements for each visible item.
193pub fn generate_legend_elements(
194    series_names: &[String],
195    colors: &[String],
196    chart_width: f64,
197    y_position: f64,
198    mark: LegendMark,
199    theme: &crate::theme::Theme,
200) -> Vec<ChartElement> {
201    if series_names.len() <= 1 {
202        return Vec::new();
203    }
204
205    let config = LegendConfig {
206        text_metrics: TextMetrics::from_theme_legend(theme),
207        ..LegendConfig::default()
208    };
209    let result = calculate_legend_layout(series_names, colors, chart_width, &config);
210
211    let mut elements = Vec::new();
212
213    for item in &result.items {
214        if !item.visible {
215            continue;
216        }
217
218        let x = item.x;
219        let sym_y = y_position + item.y;
220
221        match mark {
222            LegendMark::Line => {
223                elements.push(ChartElement::Line {
224                    x1: x,
225                    y1: sym_y + config.symbol_size / 2.0,
226                    x2: x + config.symbol_size,
227                    y2: sym_y + config.symbol_size / 2.0,
228                    stroke: item.color.clone(),
229                    stroke_width: Some(2.5),
230                    stroke_dasharray: None,
231                    class: "legend-symbol legend-line".to_string(),
232                });
233            }
234            LegendMark::Circle => {
235                elements.push(ChartElement::Circle {
236                    cx: x + config.symbol_size / 2.0,
237                    cy: sym_y + config.symbol_size / 2.0,
238                    r: config.symbol_size / 2.0 - 1.0,
239                    fill: item.color.clone(),
240                    stroke: None,
241                    class: "legend-symbol legend-circle".to_string(),
242                    data: None,
243                });
244            }
245            LegendMark::Rect => {
246                elements.push(ChartElement::Rect {
247                    x,
248                    y: sym_y,
249                    width: config.symbol_size,
250                    height: config.symbol_size,
251                    fill: item.color.clone(),
252                    stroke: None,
253                    rx: None,
254                    ry: None,
255                    class: "legend-symbol".to_string(),
256                    data: None,
257                    animation_origin: None,
258                });
259            }
260        }
261
262        let ts = TextStyle::for_role(theme, TextRole::LegendLabel);
263        elements.push(ChartElement::Text {
264            x: x + config.symbol_size + config.symbol_text_gap,
265            y: sym_y + 10.0,
266            content: item.label.clone(),
267            anchor: TextAnchor::Start,
268            dominant_baseline: None,
269            transform: None,
270            font_family: ts.font_family,
271            font_size: ts.font_size,
272            font_weight: ts.font_weight,
273            letter_spacing: ts.letter_spacing,
274            text_transform: ts.text_transform,
275            fill: Some(theme.text_secondary.clone()),
276            class: "legend-label".to_string(),
277            data: None,
278        });
279    }
280
281    elements
282}
283
284#[cfg(test)]
285mod tests {
286    #![allow(clippy::unwrap_used)]
287    use super::*;
288
289    fn make_labels(names: &[&str]) -> Vec<String> {
290        names.iter().map(|s| s.to_string()).collect()
291    }
292
293    fn make_colors(n: usize) -> Vec<String> {
294        (0..n).map(|i| format!("#{:02x}{:02x}{:02x}", i * 30, i * 50, i * 70)).collect()
295    }
296
297    /// Phase 4: verify that non-default `legend_*` typography fields on
298    /// `Theme` are applied to the emitted `legend-label` text elements.
299    #[test]
300    fn phase4_legend_text_picks_up_theme_typography() {
301        use crate::theme::Theme;
302
303        let labels = make_labels(&["Alpha", "Beta"]);
304        let colors = make_colors(2);
305        let theme = Theme {
306            legend_font_family: "Georgia, serif".into(),
307            legend_font_weight: 800,
308            legend_font_size: 15.0,
309            ..Theme::default()
310        };
311
312        let elements = generate_legend_elements(
313            &labels,
314            &colors,
315            400.0,
316            100.0,
317            LegendMark::Rect,
318            &theme,
319        );
320
321        let mut label_hits = 0;
322        for el in &elements {
323            if let ChartElement::Text {
324                class,
325                font_family,
326                font_weight,
327                font_size,
328                ..
329            } = el
330            {
331                if class == "legend-label" {
332                    label_hits += 1;
333                    assert_eq!(
334                        font_family.as_deref(),
335                        Some("Georgia, serif"),
336                        "legend-label must carry theme.legend_font_family"
337                    );
338                    assert_eq!(
339                        font_weight.as_deref(),
340                        Some("800"),
341                        "legend-label must carry theme.legend_font_weight"
342                    );
343                    assert_eq!(
344                        font_size.as_deref(),
345                        Some("15px"),
346                        "legend-label must carry theme.legend_font_size"
347                    );
348                }
349            }
350        }
351        assert_eq!(
352            label_hits, 2,
353            "expected one text per legend item"
354        );
355    }
356
357    /// Phase 4: with a default theme, the legend-label text must NOT carry
358    /// `font-family` / `font-weight` / `letter-spacing` / `text-transform`
359    /// overrides and must restate the legacy 11px font-size. This is the
360    /// byte-identity guarantee at the unit-test level.
361    #[test]
362    fn phase4_legend_text_default_theme_preserves_legacy_emission() {
363        use crate::theme::Theme;
364
365        let labels = make_labels(&["Alpha", "Beta"]);
366        let colors = make_colors(2);
367        let theme = Theme::default();
368
369        let elements = generate_legend_elements(
370            &labels,
371            &colors,
372            400.0,
373            100.0,
374            LegendMark::Rect,
375            &theme,
376        );
377
378        for el in &elements {
379            if let ChartElement::Text {
380                class,
381                font_family,
382                font_weight,
383                letter_spacing,
384                text_transform,
385                font_size,
386                ..
387            } = el
388            {
389                if class == "legend-label" {
390                    assert!(font_family.is_none(), "default theme must not set font-family");
391                    assert!(font_weight.is_none(), "default theme must not set font-weight");
392                    assert!(letter_spacing.is_none(), "default theme must not set letter-spacing");
393                    assert!(text_transform.is_none(), "default theme must not set text-transform");
394                    assert_eq!(
395                        font_size.as_deref(),
396                        Some("11px"),
397                        "default theme must keep the legacy 11px legend size"
398                    );
399                }
400            }
401        }
402    }
403
404    #[test]
405    fn legend_single_row() {
406        let labels = make_labels(&["Alpha", "Beta", "Gamma"]);
407        let colors = make_colors(3);
408        let config = LegendConfig {
409            alignment: LegendAlignment::Left,
410            ..Default::default()
411        };
412        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
413        assert_eq!(result.items.len(), 3);
414        assert_eq!(result.overflow_count, 0);
415        // All items should be on row 0
416        for item in &result.items {
417            assert_eq!(item.row, 0);
418            assert!(item.visible);
419        }
420        assert!((result.total_height - 20.0).abs() < f64::EPSILON);
421    }
422
423    #[test]
424    fn legend_multi_row() {
425        // Use many items with a narrow available width to force wrapping
426        let labels = make_labels(&["Series One", "Series Two", "Series Three", "Series Four", "Series Five"]);
427        let colors = make_colors(5);
428        let config = LegendConfig {
429            alignment: LegendAlignment::Left,
430            ..Default::default()
431        };
432        // Narrow width: each item is ~(12 + 6 + 70 + 12) = ~100px for "Series One" (10 chars * 7 = 70)
433        let result = calculate_legend_layout(&labels, &colors, 220.0, &config);
434        assert!(result.items.iter().any(|item| item.row > 0),
435            "Expected items to wrap to multiple rows");
436        assert_eq!(result.overflow_count, 0); // 5 items should fit in 3 rows
437    }
438
439    #[test]
440    fn legend_overflow() {
441        // Many items, very narrow width, only 1 row allowed
442        let labels = make_labels(&["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]);
443        let colors = make_colors(10);
444        let config = LegendConfig {
445            max_rows: 1,
446            alignment: LegendAlignment::Left,
447            ..Default::default()
448        };
449        // Each item ~(12+6+7+12)=37px, 10 items = 370px needed, only 100 available
450        let result = calculate_legend_layout(&labels, &colors, 100.0, &config);
451        assert!(result.overflow_count > 0,
452            "Expected overflow, got 0 overflow items");
453    }
454
455    #[test]
456    fn legend_empty() {
457        let labels: Vec<String> = vec![];
458        let colors: Vec<String> = vec![];
459        let config = LegendConfig::default();
460        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
461        assert!(result.items.is_empty());
462        assert_eq!(result.overflow_count, 0);
463    }
464
465    #[test]
466    fn legend_center_alignment() {
467        let labels = make_labels(&["A", "B"]);
468        let colors = make_colors(2);
469        let config = LegendConfig {
470            alignment: LegendAlignment::Center,
471            ..Default::default()
472        };
473        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
474        // With center alignment and wide available width, first item x should be > 0
475        assert!(result.items[0].x > 0.0,
476            "Expected first item to be offset for centering, got x={}", result.items[0].x);
477    }
478
479    #[test]
480    fn legend_label_truncation() {
481        let labels = vec!["This is a very long label that exceeds the maximum".to_string()];
482        let colors = make_colors(1);
483        let config = LegendConfig {
484            alignment: LegendAlignment::Left,
485            ..Default::default()
486        };
487        // Use a narrow width to force truncation (truncation is pixel-width based)
488        let result = calculate_legend_layout(&labels, &colors, 200.0, &config);
489        assert!(result.items[0].label.ends_with('\u{2026}'),
490            "Expected truncated label with ellipsis, got '{}'", result.items[0].label);
491    }
492
493    #[test]
494    fn legend_colors_assigned() {
495        let labels = make_labels(&["A", "B", "C"]);
496        let colors = vec!["red".to_string(), "green".to_string(), "blue".to_string()];
497        let config = LegendConfig {
498            alignment: LegendAlignment::Left,
499            ..Default::default()
500        };
501        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
502        assert_eq!(result.items[0].color, "red");
503        assert_eq!(result.items[1].color, "green");
504        assert_eq!(result.items[2].color, "blue");
505    }
506}