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(loc: Loc, plot_area: &Rect, box_width: f64, box_height: f64) -> (f64, f64) {
147 let left = plot_area.x + EDGE_MARGIN;
148 let right = plot_area.right() - box_width - EDGE_MARGIN;
149 let top = plot_area.y + EDGE_MARGIN;
150 let bottom = plot_area.bottom() - box_height - EDGE_MARGIN;
151 let center_x = plot_area.x + (plot_area.width - box_width) / 2.0;
152 let center_y = plot_area.y + (plot_area.height - box_height) / 2.0;
153
154 match loc {
155 Loc::UpperRight => (right, top),
157 Loc::UpperLeft => (left, top),
158 Loc::LowerLeft => (left, bottom),
159 Loc::LowerRight => (right, bottom),
160
161 Loc::Right | Loc::CenterRight => (right, center_y),
163 Loc::CenterLeft => (left, center_y),
164 Loc::UpperCenter => (center_x, top),
165 Loc::LowerCenter => (center_x, bottom),
166
167 Loc::Center => (center_x, center_y),
169
170 Loc::Best => (right, top),
174
175 #[allow(unreachable_patterns)]
178 _ => (right, top),
179 }
180}
181
182pub fn draw_legend(
219 renderer: &mut impl Renderer,
220 entries: &[LegendEntry],
221 plot_area: &Rect,
222 loc: Loc,
223 theme: &Theme,
224) {
225 if entries.is_empty() {
226 return;
227 }
228
229 let mut text_style = TextStyle::new(theme.tick_label_size);
231 text_style.color = theme.text_color;
232 if let Some(ref family) = theme.font_family {
233 text_style.family = Some(family.clone());
234 }
235
236 let (box_width, box_height) = measure_legend(renderer, entries, &text_style);
238 let (bx, by) = position_legend(loc, plot_area, box_width, box_height);
239 let legend_rect = Rect::new(bx, by, box_width, box_height);
240
241 let bg_path = Path::rect(legend_rect);
243 let bg_paint = Paint::new(Color::new(255, 255, 255, 230));
244 renderer.fill_path(&bg_path, &bg_paint, Affine::IDENTITY);
245
246 let border_paint = Paint::new(Color::rgb(200, 200, 200));
248 let border_stroke = Stroke::new(BORDER_STROKE);
249 renderer.stroke_path(&bg_path, &border_paint, &border_stroke, Affine::IDENTITY);
250
251 for (i, entry) in entries.iter().enumerate() {
253 let row_center_y = by + PADDING + i as f64 * ROW_HEIGHT + ROW_HEIGHT / 2.0;
254 let swatch_x = bx + PADDING;
255
256 draw_swatch(renderer, entry, swatch_x, row_center_y);
257 draw_label(renderer, entry, swatch_x, row_center_y, &text_style);
258 }
259}
260
261fn draw_swatch(renderer: &mut impl Renderer, entry: &LegendEntry, x: f64, center_y: f64) {
263 let paint = Paint::new(entry.color);
264
265 match entry.swatch {
266 SwatchKind::Line => {
267 let mut line = Path::new();
268 line.move_to(x, center_y);
269 line.line_to(x + SWATCH_WIDTH, center_y);
270 let stroke = Stroke::new(LINE_SWATCH_STROKE);
271 renderer.stroke_path(&line, &paint, &stroke, Affine::IDENTITY);
272 }
273 SwatchKind::Filled => {
274 let rect = Rect::new(
275 x,
276 center_y - SWATCH_HALF_HEIGHT,
277 SWATCH_WIDTH,
278 SWATCH_HALF_HEIGHT * 2.0,
279 );
280 let path = Path::rect(rect);
281 renderer.fill_path(&path, &paint, Affine::IDENTITY);
282 }
283 }
284}
285
286fn draw_label(
289 renderer: &mut impl Renderer,
290 entry: &LegendEntry,
291 swatch_x: f64,
292 center_y: f64,
293 text_style: &TextStyle,
294) {
295 let text_x = swatch_x + SWATCH_WIDTH + TEXT_GAP;
296 let text_y = center_y + text_style.size * 0.35;
299 renderer.draw_text(
300 &entry.label,
301 Point::new(text_x, text_y),
302 text_style,
303 Affine::IDENTITY,
304 );
305}
306
307#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::primitives::Image;
315
316 struct StubRenderer {
318 width: u32,
319 height: u32,
320 fill_count: usize,
321 stroke_count: usize,
322 text_count: usize,
323 texts: Vec<String>,
324 }
325
326 impl StubRenderer {
327 fn new(w: u32, h: u32) -> Self {
328 Self {
329 width: w,
330 height: h,
331 fill_count: 0,
332 stroke_count: 0,
333 text_count: 0,
334 texts: Vec::new(),
335 }
336 }
337 }
338
339 impl Renderer for StubRenderer {
340 fn size(&self) -> (u32, u32) {
341 (self.width, self.height)
342 }
343
344 fn fill_path(&mut self, _path: &Path, _paint: &Paint, _transform: Affine) {
345 self.fill_count += 1;
346 }
347
348 fn stroke_path(
349 &mut self,
350 _path: &Path,
351 _paint: &Paint,
352 _stroke: &Stroke,
353 _transform: Affine,
354 ) {
355 self.stroke_count += 1;
356 }
357
358 fn draw_text(&mut self, text: &str, _pos: Point, _style: &TextStyle, _transform: Affine) {
359 self.text_count += 1;
360 self.texts.push(text.to_string());
361 }
362
363 fn draw_image(&mut self, _img: &Image, _dst: Rect, _transform: Affine) {}
364
365 fn push_clip(&mut self, _path: &Path, _transform: Affine) {}
366
367 fn pop_clip(&mut self) {}
368
369 fn measure_text(&self, text: &str, style: &TextStyle) -> (f64, f64) {
370 let w = text.len() as f64 * style.size * 0.6;
372 let h = style.size;
373 (w, h)
374 }
375
376 fn finalize(self) -> Vec<u8> {
377 Vec::new()
378 }
379 }
380
381 #[test]
386 fn entry_line_constructor() {
387 let e = LegendEntry::line("Temperature", Color::TAB_BLUE);
388 assert_eq!(e.label, "Temperature");
389 assert_eq!(e.color, Color::TAB_BLUE);
390 assert_eq!(e.swatch, SwatchKind::Line);
391 }
392
393 #[test]
394 fn entry_filled_constructor() {
395 let e = LegendEntry::filled("Rainfall", Color::TAB_GREEN);
396 assert_eq!(e.label, "Rainfall");
397 assert_eq!(e.color, Color::TAB_GREEN);
398 assert_eq!(e.swatch, SwatchKind::Filled);
399 }
400
401 #[test]
402 fn entry_accepts_string_type() {
403 let owned = String::from("Owned label");
404 let e = LegendEntry::line(owned, Color::TAB_RED);
405 assert_eq!(e.label, "Owned label");
406 }
407
408 #[test]
413 fn measure_empty_returns_zero() {
414 let r = StubRenderer::new(800, 600);
415 let style = TextStyle::new(9.0);
416 let (w, h) = measure_legend(&r, &[], &style);
417 assert_eq!(w, 0.0);
418 assert_eq!(h, 0.0);
419 }
420
421 #[test]
422 fn measure_single_entry() {
423 let r = StubRenderer::new(800, 600);
424 let style = TextStyle::new(9.0);
425 let entries = vec![LegendEntry::line("sin(x)", Color::TAB_BLUE)];
426 let (w, h) = measure_legend(&r, &entries, &style);
427
428 let label_w = 6.0 * 9.0 * 0.6; let expected_w = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + label_w;
431 assert!((w - expected_w).abs() < 1e-9);
432
433 let expected_h = PADDING * 2.0 + ROW_HEIGHT;
435 assert!((h - expected_h).abs() < 1e-9);
436 }
437
438 #[test]
439 fn measure_uses_longest_label() {
440 let r = StubRenderer::new(800, 600);
441 let style = TextStyle::new(9.0);
442 let entries = vec![
443 LegendEntry::line("A", Color::TAB_BLUE),
444 LegendEntry::filled("Much longer label", Color::TAB_RED),
445 ];
446 let (w, _) = measure_legend(&r, &entries, &style);
447
448 let long_label_w = 17.0 * 9.0 * 0.6; let expected_w = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + long_label_w;
450 assert!((w - expected_w).abs() < 1e-9);
451 }
452
453 #[test]
458 fn position_upper_right() {
459 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
460 let (x, y) = position_legend(Loc::UpperRight, &area, 100.0, 60.0);
461 assert!((x - (area.right() - 100.0 - EDGE_MARGIN)).abs() < 1e-9);
462 assert!((y - (area.y + EDGE_MARGIN)).abs() < 1e-9);
463 }
464
465 #[test]
466 fn position_upper_left() {
467 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
468 let (x, y) = position_legend(Loc::UpperLeft, &area, 100.0, 60.0);
469 assert!((x - (area.x + EDGE_MARGIN)).abs() < 1e-9);
470 assert!((y - (area.y + EDGE_MARGIN)).abs() < 1e-9);
471 }
472
473 #[test]
474 fn position_lower_left() {
475 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
476 let (x, y) = position_legend(Loc::LowerLeft, &area, 100.0, 60.0);
477 assert!((x - (area.x + EDGE_MARGIN)).abs() < 1e-9);
478 assert!((y - (area.bottom() - 60.0 - EDGE_MARGIN)).abs() < 1e-9);
479 }
480
481 #[test]
482 fn position_lower_right() {
483 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
484 let (x, y) = position_legend(Loc::LowerRight, &area, 100.0, 60.0);
485 assert!((x - (area.right() - 100.0 - EDGE_MARGIN)).abs() < 1e-9);
486 assert!((y - (area.bottom() - 60.0 - EDGE_MARGIN)).abs() < 1e-9);
487 }
488
489 #[test]
490 fn position_center() {
491 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
492 let (x, y) = position_legend(Loc::Center, &area, 100.0, 60.0);
493 assert!((x - 350.0).abs() < 1e-9);
494 assert!((y - 270.0).abs() < 1e-9);
495 }
496
497 #[test]
498 fn position_center_left() {
499 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
500 let (x, y) = position_legend(Loc::CenterLeft, &area, 100.0, 60.0);
501 assert!((x - (area.x + EDGE_MARGIN)).abs() < 1e-9);
502 let expected_y = area.y + (area.height - 60.0) / 2.0;
503 assert!((y - expected_y).abs() < 1e-9);
504 }
505
506 #[test]
507 fn position_center_right() {
508 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
509 let (x, y) = position_legend(Loc::CenterRight, &area, 100.0, 60.0);
510 assert!((x - (area.right() - 100.0 - EDGE_MARGIN)).abs() < 1e-9);
511 let expected_y = area.y + (area.height - 60.0) / 2.0;
512 assert!((y - expected_y).abs() < 1e-9);
513 }
514
515 #[test]
516 fn position_right_aliases_center_right() {
517 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
518 let right = position_legend(Loc::Right, &area, 100.0, 60.0);
519 let center_right = position_legend(Loc::CenterRight, &area, 100.0, 60.0);
520 assert_eq!(right, center_right);
521 }
522
523 #[test]
524 fn position_upper_center() {
525 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
526 let (x, y) = position_legend(Loc::UpperCenter, &area, 100.0, 60.0);
527 assert!((x - 350.0).abs() < 1e-9);
528 assert!((y - EDGE_MARGIN).abs() < 1e-9);
529 }
530
531 #[test]
532 fn position_lower_center() {
533 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
534 let (x, y) = position_legend(Loc::LowerCenter, &area, 100.0, 60.0);
535 assert!((x - 350.0).abs() < 1e-9);
536 assert!((y - (600.0 - 60.0 - EDGE_MARGIN)).abs() < 1e-9);
537 }
538
539 #[test]
540 fn position_best_defaults_to_upper_right() {
541 let area = Rect::new(50.0, 30.0, 700.0, 500.0);
542 let best = position_legend(Loc::Best, &area, 100.0, 60.0);
543 let upper_right = position_legend(Loc::UpperRight, &area, 100.0, 60.0);
544 assert_eq!(best, upper_right);
545 }
546
547 #[test]
552 fn draw_legend_empty_is_noop() {
553 let mut r = StubRenderer::new(800, 600);
554 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
555 draw_legend(&mut r, &[], &area, Loc::UpperRight, &Theme::default());
556 assert_eq!(r.fill_count, 0);
557 assert_eq!(r.stroke_count, 0);
558 assert_eq!(r.text_count, 0);
559 }
560
561 #[test]
562 fn draw_legend_single_line_entry() {
563 let mut r = StubRenderer::new(800, 600);
564 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
565 let entries = vec![LegendEntry::line("Series A", Color::TAB_BLUE)];
566 draw_legend(&mut r, &entries, &area, Loc::UpperRight, &Theme::default());
567
568 assert_eq!(r.fill_count, 1);
570 assert_eq!(r.stroke_count, 2);
572 assert_eq!(r.text_count, 1);
574 assert_eq!(r.texts[0], "Series A");
575 }
576
577 #[test]
578 fn draw_legend_single_filled_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::filled("Bars", Color::TAB_ORANGE)];
582 draw_legend(&mut r, &entries, &area, Loc::LowerLeft, &Theme::default());
583
584 assert_eq!(r.fill_count, 2);
586 assert_eq!(r.stroke_count, 1);
588 assert_eq!(r.text_count, 1);
589 assert_eq!(r.texts[0], "Bars");
590 }
591
592 #[test]
593 fn draw_legend_multiple_entries() {
594 let mut r = StubRenderer::new(800, 600);
595 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
596 let entries = vec![
597 LegendEntry::line("Alpha", Color::TAB_BLUE),
598 LegendEntry::filled("Beta", Color::TAB_GREEN),
599 LegendEntry::line("Gamma", Color::TAB_RED),
600 ];
601 draw_legend(&mut r, &entries, &area, Loc::Center, &Theme::default());
602
603 assert_eq!(r.fill_count, 2);
605 assert_eq!(r.stroke_count, 3);
607 assert_eq!(r.text_count, 3);
609 assert_eq!(r.texts, vec!["Alpha", "Beta", "Gamma"]);
610 }
611
612 #[test]
613 fn draw_legend_respects_theme_font_family() {
614 let mut r = StubRenderer::new(800, 600);
616 let area = Rect::new(0.0, 0.0, 800.0, 600.0);
617 let entries = vec![LegendEntry::line("Test", Color::TAB_BLUE)];
618 let theme = Theme::publication(); draw_legend(&mut r, &entries, &area, Loc::UpperLeft, &theme);
620 assert_eq!(r.text_count, 1);
621 }
622}