1use crate::element::{ChartElement, TextAnchor, TextRole, TextStyle};
2use crate::layout::labels::{measure_text, TextMetrics};
3
4#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum LegendMark {
7 Rect, Line, Circle, }
11
12#[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
25pub 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 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#[derive(Debug, Clone)]
63pub struct LegendLayoutResult {
64 pub items: Vec<LegendItem>,
65 pub total_height: f64,
66 pub overflow_count: usize,
67}
68
69pub 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 let mut rows: Vec<(usize, usize, f64)> = vec![(0, 0, 0.0)];
93
94 for (i, label) in labels.iter().enumerate() {
95 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 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 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 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 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
191pub 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 #[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 #[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 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 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 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); }
438
439 #[test]
440 fn legend_overflow() {
441 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 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 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 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}