1use crate::primitives::{Affine, Color, Paint, Path, Point, Rect, Stroke, TextStyle};
20use crate::renderer::Renderer;
21use crate::theme::{Loc, Theme};
22
23const PADDING: f64 = 8.0;
29
30const SWATCH_WIDTH: f64 = 22.0;
32
33const SWATCH_HALF_HEIGHT: f64 = 4.0;
35
36const TEXT_GAP: f64 = 6.0;
38
39const ROW_HEIGHT: f64 = 18.0;
41
42const EDGE_MARGIN: f64 = 8.0;
44
45const LINE_SWATCH_STROKE: f64 = 2.0;
47
48const BORDER_STROKE: f64 = 0.5;
50
51#[derive(Debug, Clone)]
61pub struct LegendEntry {
62 pub label: String,
64 pub color: Color,
66 pub swatch: SwatchKind,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SwatchKind {
73 Line,
75 Filled,
78}
79
80impl LegendEntry {
81 pub fn line(label: impl Into<String>, color: Color) -> Self {
86 Self {
87 label: label.into(),
88 color,
89 swatch: SwatchKind::Line,
90 }
91 }
92
93 pub fn filled(label: impl Into<String>, color: Color) -> Self {
98 Self {
99 label: label.into(),
100 color,
101 swatch: SwatchKind::Filled,
102 }
103 }
104}
105
106fn measure_legend(
115 renderer: &impl Renderer,
116 entries: &[LegendEntry],
117 text_style: &TextStyle,
118) -> (f64, f64) {
119 if entries.is_empty() {
120 return (0.0, 0.0);
121 }
122
123 let max_label_width: f64 = entries
124 .iter()
125 .map(|e| {
126 let (w, _) = renderer.measure_text(&e.label, text_style);
127 w
128 })
129 .fold(0.0_f64, f64::max);
130
131 let width = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + max_label_width;
132 let height = PADDING * 2.0 + entries.len() as f64 * ROW_HEIGHT;
133
134 (width, height)
135}
136
137fn position_legend(
147 loc: Loc,
148 plot_area: &Rect,
149 box_width: f64,
150 box_height: f64,
151) -> (f64, f64) {
152 let left = plot_area.x + EDGE_MARGIN;
153 let right = plot_area.right() - box_width - EDGE_MARGIN;
154 let top = plot_area.y + EDGE_MARGIN;
155 let bottom = plot_area.bottom() - box_height - EDGE_MARGIN;
156 let center_x = plot_area.x + (plot_area.width - box_width) / 2.0;
157 let center_y = plot_area.y + (plot_area.height - box_height) / 2.0;
158
159 match loc {
160 Loc::UpperRight => (right, top),
162 Loc::UpperLeft => (left, top),
163 Loc::LowerLeft => (left, bottom),
164 Loc::LowerRight => (right, bottom),
165
166 Loc::Right | Loc::CenterRight => (right, center_y),
168 Loc::CenterLeft => (left, center_y),
169 Loc::UpperCenter => (center_x, top),
170 Loc::LowerCenter => (center_x, bottom),
171
172 Loc::Center => (center_x, center_y),
174
175 Loc::Best => (right, top),
179
180 #[allow(unreachable_patterns)]
183 _ => (right, top),
184 }
185}
186
187pub fn draw_legend(
224 renderer: &mut impl Renderer,
225 entries: &[LegendEntry],
226 plot_area: &Rect,
227 loc: Loc,
228 theme: &Theme,
229) {
230 if entries.is_empty() {
231 return;
232 }
233
234 let mut text_style = TextStyle::new(theme.tick_label_size);
236 text_style.color = theme.text_color;
237 if let Some(ref family) = theme.font_family {
238 text_style.family = Some(family.clone());
239 }
240
241 let (box_width, box_height) = measure_legend(renderer, entries, &text_style);
243 let (bx, by) = position_legend(loc, plot_area, box_width, box_height);
244 let legend_rect = Rect::new(bx, by, box_width, box_height);
245
246 let bg_path = Path::rect(legend_rect);
248 let bg_paint = Paint::new(Color::new(255, 255, 255, 230));
249 renderer.fill_path(&bg_path, &bg_paint, Affine::IDENTITY);
250
251 let border_paint = Paint::new(Color::rgb(200, 200, 200));
253 let border_stroke = Stroke::new(BORDER_STROKE);
254 renderer.stroke_path(&bg_path, &border_paint, &border_stroke, Affine::IDENTITY);
255
256 for (i, entry) in entries.iter().enumerate() {
258 let row_center_y = by + PADDING + i as f64 * ROW_HEIGHT + ROW_HEIGHT / 2.0;
259 let swatch_x = bx + PADDING;
260
261 draw_swatch(renderer, entry, swatch_x, row_center_y);
262 draw_label(renderer, entry, swatch_x, row_center_y, &text_style);
263 }
264}
265
266fn draw_swatch(
268 renderer: &mut impl Renderer,
269 entry: &LegendEntry,
270 x: f64,
271 center_y: f64,
272) {
273 let paint = Paint::new(entry.color);
274
275 match entry.swatch {
276 SwatchKind::Line => {
277 let mut line = Path::new();
278 line.move_to(x, center_y);
279 line.line_to(x + SWATCH_WIDTH, center_y);
280 let stroke = Stroke::new(LINE_SWATCH_STROKE);
281 renderer.stroke_path(&line, &paint, &stroke, Affine::IDENTITY);
282 }
283 SwatchKind::Filled => {
284 let rect = Rect::new(
285 x,
286 center_y - SWATCH_HALF_HEIGHT,
287 SWATCH_WIDTH,
288 SWATCH_HALF_HEIGHT * 2.0,
289 );
290 let path = Path::rect(rect);
291 renderer.fill_path(&path, &paint, Affine::IDENTITY);
292 }
293 }
294}
295
296fn draw_label(
299 renderer: &mut impl Renderer,
300 entry: &LegendEntry,
301 swatch_x: f64,
302 center_y: f64,
303 text_style: &TextStyle,
304) {
305 let text_x = swatch_x + SWATCH_WIDTH + TEXT_GAP;
306 let text_y = center_y + text_style.size * 0.35;
309 renderer.draw_text(
310 &entry.label,
311 Point::new(text_x, text_y),
312 text_style,
313 Affine::IDENTITY,
314 );
315}
316
317#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::primitives::Image;
325
326 struct StubRenderer {
328 width: u32,
329 height: u32,
330 fill_count: usize,
331 stroke_count: usize,
332 text_count: usize,
333 texts: Vec<String>,
334 }
335
336 impl StubRenderer {
337 fn new(w: u32, h: u32) -> Self {
338 Self {
339 width: w,
340 height: h,
341 fill_count: 0,
342 stroke_count: 0,
343 text_count: 0,
344 texts: Vec::new(),
345 }
346 }
347 }
348
349 impl Renderer for StubRenderer {
350 fn size(&self) -> (u32, u32) {
351 (self.width, self.height)
352 }
353
354 fn fill_path(&mut self, _path: &Path, _paint: &Paint, _transform: Affine) {
355 self.fill_count += 1;
356 }
357
358 fn stroke_path(
359 &mut self,
360 _path: &Path,
361 _paint: &Paint,
362 _stroke: &Stroke,
363 _transform: Affine,
364 ) {
365 self.stroke_count += 1;
366 }
367
368 fn draw_text(
369 &mut self,
370 text: &str,
371 _pos: Point,
372 _style: &TextStyle,
373 _transform: Affine,
374 ) {
375 self.text_count += 1;
376 self.texts.push(text.to_string());
377 }
378
379 fn draw_image(&mut self, _img: &Image, _dst: Rect, _transform: Affine) {}
380
381 fn push_clip(&mut self, _path: &Path, _transform: Affine) {}
382
383 fn pop_clip(&mut self) {}
384
385 fn measure_text(&self, text: &str, style: &TextStyle) -> (f64, f64) {
386 let w = text.len() as f64 * style.size * 0.6;
388 let h = style.size;
389 (w, h)
390 }
391
392 fn finalize(self) -> Vec<u8> {
393 Vec::new()
394 }
395 }
396
397 #[test]
402 fn entry_line_constructor() {
403 let e = LegendEntry::line("Temperature", Color::TAB_BLUE);
404 assert_eq!(e.label, "Temperature");
405 assert_eq!(e.color, Color::TAB_BLUE);
406 assert_eq!(e.swatch, SwatchKind::Line);
407 }
408
409 #[test]
410 fn entry_filled_constructor() {
411 let e = LegendEntry::filled("Rainfall", Color::TAB_GREEN);
412 assert_eq!(e.label, "Rainfall");
413 assert_eq!(e.color, Color::TAB_GREEN);
414 assert_eq!(e.swatch, SwatchKind::Filled);
415 }
416
417 #[test]
418 fn entry_accepts_string_type() {
419 let owned = String::from("Owned label");
420 let e = LegendEntry::line(owned, Color::TAB_RED);
421 assert_eq!(e.label, "Owned label");
422 }
423
424 #[test]
429 fn measure_empty_returns_zero() {
430 let r = StubRenderer::new(800, 600);
431 let style = TextStyle::new(9.0);
432 let (w, h) = measure_legend(&r, &[], &style);
433 assert_eq!(w, 0.0);
434 assert_eq!(h, 0.0);
435 }
436
437 #[test]
438 fn measure_single_entry() {
439 let r = StubRenderer::new(800, 600);
440 let style = TextStyle::new(9.0);
441 let entries = vec![LegendEntry::line("sin(x)", Color::TAB_BLUE)];
442 let (w, h) = measure_legend(&r, &entries, &style);
443
444 let label_w = 6.0 * 9.0 * 0.6; let expected_w = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + label_w;
447 assert!((w - expected_w).abs() < 1e-9);
448
449 let expected_h = PADDING * 2.0 + ROW_HEIGHT;
451 assert!((h - expected_h).abs() < 1e-9);
452 }
453
454 #[test]
455 fn measure_uses_longest_label() {
456 let r = StubRenderer::new(800, 600);
457 let style = TextStyle::new(9.0);
458 let entries = vec![
459 LegendEntry::line("A", Color::TAB_BLUE),
460 LegendEntry::filled("Much longer label", Color::TAB_RED),
461 ];
462 let (w, _) = measure_legend(&r, &entries, &style);
463
464 let long_label_w = 17.0 * 9.0 * 0.6; let expected_w = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + long_label_w;
466 assert!((w - expected_w).abs() < 1e-9);
467 }
468
469 #[test]
474 fn position_upper_right() {
475 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
476 let (x, y) = position_legend(Loc::UpperRight, &area, 100.0, 60.0);
477 assert!((x - (area.right() - 100.0 - EDGE_MARGIN)).abs() < 1e-9);
478 assert!((y - (area.y + EDGE_MARGIN)).abs() < 1e-9);
479 }
480
481 #[test]
482 fn position_upper_left() {
483 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
484 let (x, y) = position_legend(Loc::UpperLeft, &area, 100.0, 60.0);
485 assert!((x - (area.x + EDGE_MARGIN)).abs() < 1e-9);
486 assert!((y - (area.y + EDGE_MARGIN)).abs() < 1e-9);
487 }
488
489 #[test]
490 fn position_lower_left() {
491 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
492 let (x, y) = position_legend(Loc::LowerLeft, &area, 100.0, 60.0);
493 assert!((x - (area.x + EDGE_MARGIN)).abs() < 1e-9);
494 assert!((y - (area.bottom() - 60.0 - EDGE_MARGIN)).abs() < 1e-9);
495 }
496
497 #[test]
498 fn position_lower_right() {
499 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
500 let (x, y) = position_legend(Loc::LowerRight, &area, 100.0, 60.0);
501 assert!((x - (area.right() - 100.0 - EDGE_MARGIN)).abs() < 1e-9);
502 assert!((y - (area.bottom() - 60.0 - EDGE_MARGIN)).abs() < 1e-9);
503 }
504
505 #[test]
506 fn position_center() {
507 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
508 let (x, y) = position_legend(Loc::Center, &area, 100.0, 60.0);
509 assert!((x - 350.0).abs() < 1e-9);
510 assert!((y - 270.0).abs() < 1e-9);
511 }
512
513 #[test]
514 fn position_center_left() {
515 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
516 let (x, y) = position_legend(Loc::CenterLeft, &area, 100.0, 60.0);
517 assert!((x - (area.x + EDGE_MARGIN)).abs() < 1e-9);
518 let expected_y = area.y + (area.height - 60.0) / 2.0;
519 assert!((y - expected_y).abs() < 1e-9);
520 }
521
522 #[test]
523 fn position_center_right() {
524 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
525 let (x, y) = position_legend(Loc::CenterRight, &area, 100.0, 60.0);
526 assert!((x - (area.right() - 100.0 - EDGE_MARGIN)).abs() < 1e-9);
527 let expected_y = area.y + (area.height - 60.0) / 2.0;
528 assert!((y - expected_y).abs() < 1e-9);
529 }
530
531 #[test]
532 fn position_right_aliases_center_right() {
533 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
534 let right = position_legend(Loc::Right, &area, 100.0, 60.0);
535 let center_right = position_legend(Loc::CenterRight, &area, 100.0, 60.0);
536 assert_eq!(right, center_right);
537 }
538
539 #[test]
540 fn position_upper_center() {
541 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
542 let (x, y) = position_legend(Loc::UpperCenter, &area, 100.0, 60.0);
543 assert!((x - 350.0).abs() < 1e-9);
544 assert!((y - EDGE_MARGIN).abs() < 1e-9);
545 }
546
547 #[test]
548 fn position_lower_center() {
549 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
550 let (x, y) = position_legend(Loc::LowerCenter, &area, 100.0, 60.0);
551 assert!((x - 350.0).abs() < 1e-9);
552 assert!((y - (600.0 - 60.0 - EDGE_MARGIN)).abs() < 1e-9);
553 }
554
555 #[test]
556 fn position_best_defaults_to_upper_right() {
557 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
558 let best = position_legend(Loc::Best, &area, 100.0, 60.0);
559 let upper_right = position_legend(Loc::UpperRight, &area, 100.0, 60.0);
560 assert_eq!(best, upper_right);
561 }
562
563 #[test]
568 fn draw_legend_empty_is_noop() {
569 let mut r = StubRenderer::new(800, 600);
570 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
571 draw_legend(&mut r, &[], &area, Loc::UpperRight, &Theme::default());
572 assert_eq!(r.fill_count, 0);
573 assert_eq!(r.stroke_count, 0);
574 assert_eq!(r.text_count, 0);
575 }
576
577 #[test]
578 fn draw_legend_single_line_entry() {
579 let mut r = StubRenderer::new(800, 600);
580 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
581 let entries = vec![LegendEntry::line("Series A", Color::TAB_BLUE)];
582 draw_legend(&mut r, &entries, &area, Loc::UpperRight, &Theme::default());
583
584 assert_eq!(r.fill_count, 1);
586 assert_eq!(r.stroke_count, 2);
588 assert_eq!(r.text_count, 1);
590 assert_eq!(r.texts[0], "Series A");
591 }
592
593 #[test]
594 fn draw_legend_single_filled_entry() {
595 let mut r = StubRenderer::new(800, 600);
596 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
597 let entries = vec![LegendEntry::filled("Bars", Color::TAB_ORANGE)];
598 draw_legend(&mut r, &entries, &area, Loc::LowerLeft, &Theme::default());
599
600 assert_eq!(r.fill_count, 2);
602 assert_eq!(r.stroke_count, 1);
604 assert_eq!(r.text_count, 1);
605 assert_eq!(r.texts[0], "Bars");
606 }
607
608 #[test]
609 fn draw_legend_multiple_entries() {
610 let mut r = StubRenderer::new(800, 600);
611 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
612 let entries = vec![
613 LegendEntry::line("Alpha", Color::TAB_BLUE),
614 LegendEntry::filled("Beta", Color::TAB_GREEN),
615 LegendEntry::line("Gamma", Color::TAB_RED),
616 ];
617 draw_legend(&mut r, &entries, &area, Loc::Center, &Theme::default());
618
619 assert_eq!(r.fill_count, 2);
621 assert_eq!(r.stroke_count, 3);
623 assert_eq!(r.text_count, 3);
625 assert_eq!(r.texts, vec!["Alpha", "Beta", "Gamma"]);
626 }
627
628 #[test]
629 fn draw_legend_respects_theme_font_family() {
630 let mut r = StubRenderer::new(800, 600);
632 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
633 let entries = vec![LegendEntry::line("Test", Color::TAB_BLUE)];
634 let theme = Theme::publication(); draw_legend(&mut r, &entries, &area, Loc::UpperLeft, &theme);
636 assert_eq!(r.text_count, 1);
637 }
638}