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 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 #[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 #[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 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 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 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); }
437
438 #[test]
439 fn legend_overflow() {
440 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 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 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 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}