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    use super::*;
287
288    fn make_labels(names: &[&str]) -> Vec<String> {
289        names.iter().map(|s| s.to_string()).collect()
290    }
291
292    fn make_colors(n: usize) -> Vec<String> {
293        (0..n).map(|i| format!("#{:02x}{:02x}{:02x}", i * 30, i * 50, i * 70)).collect()
294    }
295
296    /// Phase 4: verify that non-default `legend_*` typography fields on
297    /// `Theme` are applied to the emitted `legend-label` text elements.
298    #[test]
299    fn phase4_legend_text_picks_up_theme_typography() {
300        use crate::theme::Theme;
301
302        let labels = make_labels(&["Alpha", "Beta"]);
303        let colors = make_colors(2);
304        let theme = Theme {
305            legend_font_family: "Georgia, serif".into(),
306            legend_font_weight: 800,
307            legend_font_size: 15.0,
308            ..Theme::default()
309        };
310
311        let elements = generate_legend_elements(
312            &labels,
313            &colors,
314            400.0,
315            100.0,
316            LegendMark::Rect,
317            &theme,
318        );
319
320        let mut label_hits = 0;
321        for el in &elements {
322            if let ChartElement::Text {
323                class,
324                font_family,
325                font_weight,
326                font_size,
327                ..
328            } = el
329            {
330                if class == "legend-label" {
331                    label_hits += 1;
332                    assert_eq!(
333                        font_family.as_deref(),
334                        Some("Georgia, serif"),
335                        "legend-label must carry theme.legend_font_family"
336                    );
337                    assert_eq!(
338                        font_weight.as_deref(),
339                        Some("800"),
340                        "legend-label must carry theme.legend_font_weight"
341                    );
342                    assert_eq!(
343                        font_size.as_deref(),
344                        Some("15px"),
345                        "legend-label must carry theme.legend_font_size"
346                    );
347                }
348            }
349        }
350        assert_eq!(
351            label_hits, 2,
352            "expected one text per legend item"
353        );
354    }
355
356    /// Phase 4: with a default theme, the legend-label text must NOT carry
357    /// `font-family` / `font-weight` / `letter-spacing` / `text-transform`
358    /// overrides and must restate the legacy 11px font-size. This is the
359    /// byte-identity guarantee at the unit-test level.
360    #[test]
361    fn phase4_legend_text_default_theme_preserves_legacy_emission() {
362        use crate::theme::Theme;
363
364        let labels = make_labels(&["Alpha", "Beta"]);
365        let colors = make_colors(2);
366        let theme = Theme::default();
367
368        let elements = generate_legend_elements(
369            &labels,
370            &colors,
371            400.0,
372            100.0,
373            LegendMark::Rect,
374            &theme,
375        );
376
377        for el in &elements {
378            if let ChartElement::Text {
379                class,
380                font_family,
381                font_weight,
382                letter_spacing,
383                text_transform,
384                font_size,
385                ..
386            } = el
387            {
388                if class == "legend-label" {
389                    assert!(font_family.is_none(), "default theme must not set font-family");
390                    assert!(font_weight.is_none(), "default theme must not set font-weight");
391                    assert!(letter_spacing.is_none(), "default theme must not set letter-spacing");
392                    assert!(text_transform.is_none(), "default theme must not set text-transform");
393                    assert_eq!(
394                        font_size.as_deref(),
395                        Some("11px"),
396                        "default theme must keep the legacy 11px legend size"
397                    );
398                }
399            }
400        }
401    }
402
403    #[test]
404    fn legend_single_row() {
405        let labels = make_labels(&["Alpha", "Beta", "Gamma"]);
406        let colors = make_colors(3);
407        let config = LegendConfig {
408            alignment: LegendAlignment::Left,
409            ..Default::default()
410        };
411        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
412        assert_eq!(result.items.len(), 3);
413        assert_eq!(result.overflow_count, 0);
414        // All items should be on row 0
415        for item in &result.items {
416            assert_eq!(item.row, 0);
417            assert!(item.visible);
418        }
419        assert!((result.total_height - 20.0).abs() < f64::EPSILON);
420    }
421
422    #[test]
423    fn legend_multi_row() {
424        // Use many items with a narrow available width to force wrapping
425        let labels = make_labels(&["Series One", "Series Two", "Series Three", "Series Four", "Series Five"]);
426        let colors = make_colors(5);
427        let config = LegendConfig {
428            alignment: LegendAlignment::Left,
429            ..Default::default()
430        };
431        // Narrow width: each item is ~(12 + 6 + 70 + 12) = ~100px for "Series One" (10 chars * 7 = 70)
432        let result = calculate_legend_layout(&labels, &colors, 220.0, &config);
433        assert!(result.items.iter().any(|item| item.row > 0),
434            "Expected items to wrap to multiple rows");
435        assert_eq!(result.overflow_count, 0); // 5 items should fit in 3 rows
436    }
437
438    #[test]
439    fn legend_overflow() {
440        // Many items, very narrow width, only 1 row allowed
441        let labels = make_labels(&["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]);
442        let colors = make_colors(10);
443        let config = LegendConfig {
444            max_rows: 1,
445            alignment: LegendAlignment::Left,
446            ..Default::default()
447        };
448        // Each item ~(12+6+7+12)=37px, 10 items = 370px needed, only 100 available
449        let result = calculate_legend_layout(&labels, &colors, 100.0, &config);
450        assert!(result.overflow_count > 0,
451            "Expected overflow, got 0 overflow items");
452    }
453
454    #[test]
455    fn legend_empty() {
456        let labels: Vec<String> = vec![];
457        let colors: Vec<String> = vec![];
458        let config = LegendConfig::default();
459        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
460        assert!(result.items.is_empty());
461        assert_eq!(result.overflow_count, 0);
462    }
463
464    #[test]
465    fn legend_center_alignment() {
466        let labels = make_labels(&["A", "B"]);
467        let colors = make_colors(2);
468        let config = LegendConfig {
469            alignment: LegendAlignment::Center,
470            ..Default::default()
471        };
472        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
473        // With center alignment and wide available width, first item x should be > 0
474        assert!(result.items[0].x > 0.0,
475            "Expected first item to be offset for centering, got x={}", result.items[0].x);
476    }
477
478    #[test]
479    fn legend_label_truncation() {
480        let labels = vec!["This is a very long label that exceeds the maximum".to_string()];
481        let colors = make_colors(1);
482        let config = LegendConfig {
483            alignment: LegendAlignment::Left,
484            ..Default::default()
485        };
486        // Use a narrow width to force truncation (truncation is pixel-width based)
487        let result = calculate_legend_layout(&labels, &colors, 200.0, &config);
488        assert!(result.items[0].label.ends_with('\u{2026}'),
489            "Expected truncated label with ellipsis, got '{}'", result.items[0].label);
490    }
491
492    #[test]
493    fn legend_colors_assigned() {
494        let labels = make_labels(&["A", "B", "C"]);
495        let colors = vec!["red".to_string(), "green".to_string(), "blue".to_string()];
496        let config = LegendConfig {
497            alignment: LegendAlignment::Left,
498            ..Default::default()
499        };
500        let result = calculate_legend_layout(&labels, &colors, 800.0, &config);
501        assert_eq!(result.items[0].color, "red");
502        assert_eq!(result.items[1].color, "green");
503        assert_eq!(result.items[2].color, "blue");
504    }
505}