1use crate::element::{ChartElement, TextAnchor};
2
3#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum LegendMark {
6 Rect, Line, Circle, }
10
11#[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
24pub 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#[derive(Debug, Clone)]
58pub struct LegendLayoutResult {
59 pub items: Vec<LegendItem>,
60 pub total_height: f64,
61 pub overflow_count: usize,
62}
63
64pub 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 let mut rows: Vec<(usize, usize, f64)> = vec![(0, 0, 0.0)];
87
88 for (i, label) in labels.iter().enumerate() {
89 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 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 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 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 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
185pub 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 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 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 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); }
313
314 #[test]
315 fn legend_overflow() {
316 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 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 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}