Skip to main content

plotkit_core/
legend.rs

1//! Legend layout and rendering.
2//!
3//! This module handles the positioning, measurement, and drawing of legend
4//! boxes within a plot area. A legend consists of one or more [`LegendEntry`]
5//! items, each displaying a color swatch (line segment or filled rectangle)
6//! alongside a text label.
7//!
8//! # Usage
9//!
10//! 1. Build a `Vec<LegendEntry>` from the artists on your axes (typically by
11//!    iterating over labeled artists and calling [`LegendEntry::line`] or
12//!    [`LegendEntry::filled`]).
13//! 2. Call [`draw_legend`] with the renderer, entries, plot area, desired
14//!    [`Loc`], and active [`Theme`].
15//!
16//! The legend is drawn *last* in the render pipeline so it appears on top of
17//! all data elements.
18
19use crate::primitives::{Affine, Color, Paint, Path, Point, Rect, Stroke, TextStyle};
20use crate::renderer::Renderer;
21use crate::theme::{Loc, Theme};
22
23// ---------------------------------------------------------------------------
24// Constants
25// ---------------------------------------------------------------------------
26
27/// Padding between the legend box border and its contents (in pixels).
28const PADDING: f64 = 8.0;
29
30/// Width of a swatch (the colored indicator) in pixels.
31const SWATCH_WIDTH: f64 = 22.0;
32
33/// Half-height of a filled (box) swatch in pixels.
34const SWATCH_HALF_HEIGHT: f64 = 4.0;
35
36/// Horizontal gap between the swatch and the label text (in pixels).
37const TEXT_GAP: f64 = 6.0;
38
39/// Vertical spacing per legend row (in pixels).
40const ROW_HEIGHT: f64 = 18.0;
41
42/// Margin between the legend box and the plot area edge (in pixels).
43const EDGE_MARGIN: f64 = 8.0;
44
45/// Stroke width used to draw line swatches.
46const LINE_SWATCH_STROKE: f64 = 2.0;
47
48/// Stroke width of the legend box border.
49const BORDER_STROKE: f64 = 0.5;
50
51// ---------------------------------------------------------------------------
52// LegendEntry
53// ---------------------------------------------------------------------------
54
55/// A single legend entry consisting of a color swatch and a text label.
56///
57/// Each entry describes how to render one row inside the legend box:
58/// - **Line** entries draw a short horizontal line segment in the given color.
59/// - **Filled** entries draw a small filled rectangle in the given color.
60#[derive(Debug, Clone)]
61pub struct LegendEntry {
62    /// The display label shown next to the swatch.
63    pub label: String,
64    /// The color used to render the swatch.
65    pub color: Color,
66    /// The visual style of the swatch.
67    pub swatch: SwatchKind,
68}
69
70/// Describes the visual representation of a legend swatch.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SwatchKind {
73    /// A short horizontal line segment, typically used for line plots.
74    Line,
75    /// A small filled rectangle, typically used for bar charts, histograms,
76    /// and fill-between areas.
77    Filled,
78}
79
80impl LegendEntry {
81    /// Creates a legend entry with a line swatch.
82    ///
83    /// Use this for line plots and any artist that is primarily represented
84    /// by a stroked path.
85    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    /// Creates a legend entry with a filled-rectangle swatch.
94    ///
95    /// Use this for bar charts, histograms, fill-between areas, and any
96    /// artist that is primarily represented by a filled region.
97    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
106// ---------------------------------------------------------------------------
107// Legend measurement
108// ---------------------------------------------------------------------------
109
110/// Measures the bounding box dimensions required by the legend.
111///
112/// Returns `(width, height)` in pixels. If `entries` is empty the returned
113/// size is `(0.0, 0.0)`.
114fn 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
137// ---------------------------------------------------------------------------
138// Legend positioning
139// ---------------------------------------------------------------------------
140
141/// Computes the top-left corner `(x, y)` of the legend box for the given
142/// [`Loc`] variant within `plot_area`.
143///
144/// All eleven `Loc` variants are handled explicitly. The box is inset from
145/// the plot area edges by [`EDGE_MARGIN`] pixels.
146fn 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        // Corner positions
161        Loc::UpperRight => (right, top),
162        Loc::UpperLeft => (left, top),
163        Loc::LowerLeft => (left, bottom),
164        Loc::LowerRight => (right, bottom),
165
166        // Edge-centered positions
167        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        // Dead center
173        Loc::Center => (center_x, center_y),
174
175        // Best: fall back to upper-right for now. A future implementation
176        // will score candidate positions by data-point occlusion and pick
177        // the one with the least overlap (see feature spec for details).
178        Loc::Best => (right, top),
179
180        // Exhaustive today, but `Loc` is `#[non_exhaustive]` so we add a
181        // catch-all that defaults to upper-right to stay forward-compatible.
182        #[allow(unreachable_patterns)]
183        _ => (right, top),
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Legend drawing
189// ---------------------------------------------------------------------------
190
191/// Renders a legend box at the specified location within the plot area.
192///
193/// The legend is drawn with a semi-transparent white background and a thin
194/// border so that it does not fully obscure data underneath. Text styling
195/// is derived from the active [`Theme`] (specifically `tick_label_size` and
196/// `text_color`).
197///
198/// If `entries` is empty this function returns immediately without drawing
199/// anything.
200///
201/// # Arguments
202///
203/// * `renderer`  - The rendering backend to draw into.
204/// * `entries`   - The legend entries to display (swatch + label each).
205/// * `plot_area` - The rectangle describing the axes / data area.
206/// * `loc`       - Where to place the legend relative to the plot area.
207/// * `theme`     - The active theme (used for font size, text color).
208///
209/// # Example
210///
211/// ```ignore
212/// use plotkit_core::legend::{LegendEntry, draw_legend};
213/// use plotkit_core::primitives::{Color, Rect};
214/// use plotkit_core::theme::{Loc, Theme};
215///
216/// let entries = vec![
217///     LegendEntry::line("Temperature", Color::TAB_BLUE),
218///     LegendEntry::filled("Rainfall", Color::TAB_GREEN),
219/// ];
220/// let area = Rect::new(60.0, 40.0, 680.0, 520.0);
221/// draw_legend(&mut renderer, &entries, &area, Loc::UpperRight, &Theme::default());
222/// ```
223pub 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    // Build the text style from the theme.
235    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    // Measure and position.
242    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    // --- Background fill ---
247    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    // --- Border stroke ---
252    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    // --- Draw each entry ---
257    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
266/// Draws the swatch (line or filled rectangle) for a single legend entry.
267fn 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
296/// Draws the text label for a single legend entry, positioned to the right
297/// of the swatch.
298fn 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    // Offset the text slightly below the row center to approximate baseline
307    // alignment with the swatch.
308    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// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::primitives::Image;
325
326    /// A minimal stub renderer that records calls for assertion.
327    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            // Approximate: 0.6 * font_size per character.
387            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    // -----------------------------------------------------------------------
398    // LegendEntry construction
399    // -----------------------------------------------------------------------
400
401    #[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    // -----------------------------------------------------------------------
425    // Measurement
426    // -----------------------------------------------------------------------
427
428    #[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        // Expected width: padding*2 + swatch_width + text_gap + label_width
445        let label_w = 6.0 * 9.0 * 0.6; // "sin(x)" is 6 chars
446        let expected_w = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + label_w;
447        assert!((w - expected_w).abs() < 1e-9);
448
449        // Expected height: padding*2 + 1 * row_height
450        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; // "Much longer label" is 17 chars
465        let expected_w = PADDING * 2.0 + SWATCH_WIDTH + TEXT_GAP + long_label_w;
466        assert!((w - expected_w).abs() < 1e-9);
467    }
468
469    // -----------------------------------------------------------------------
470    // Positioning
471    // -----------------------------------------------------------------------
472
473    #[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    // -----------------------------------------------------------------------
564    // Full draw_legend integration
565    // -----------------------------------------------------------------------
566
567    #[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        // Background fill (1) + no swatch fill for line entries = 1 fill total
585        assert_eq!(r.fill_count, 1);
586        // Border stroke (1) + line swatch (1) = 2 strokes
587        assert_eq!(r.stroke_count, 2);
588        // One text label
589        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        // Background fill (1) + swatch fill (1) = 2 fills
601        assert_eq!(r.fill_count, 2);
602        // Border stroke (1) only, no line swatch
603        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        // Background fill (1) + 1 filled swatch = 2 fills
620        assert_eq!(r.fill_count, 2);
621        // Border stroke (1) + 2 line swatches = 3 strokes
622        assert_eq!(r.stroke_count, 3);
623        // 3 text labels
624        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        // Ensure we do not panic when the theme has a font family set.
631        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(); // has font_family = Some("serif")
635        draw_legend(&mut r, &entries, &area, Loc::UpperLeft, &theme);
636        assert_eq!(r.text_count, 1);
637    }
638}