Skip to main content

ggplot_rs/guide/
legend.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3use crate::guide::config::GuideLegend;
4use crate::render::backend::{
5    DrawBackend, LineStyle, Linetype, PointStyle, RectStyle, TextAnchor, TextStyle,
6};
7use crate::render::{Rect, RenderError};
8use crate::scale::ScaleSet;
9use crate::theme::{LegendPosition, Theme};
10
11/// Which aesthetics should generate legends.
12const LEGEND_AESTHETICS: &[Aesthetic] = &[
13    Aesthetic::Color,
14    Aesthetic::Fill,
15    Aesthetic::Shape,
16    Aesthetic::Linetype,
17    Aesthetic::Size,
18    Aesthetic::Alpha,
19    Aesthetic::Linewidth,
20];
21
22/// Draw all legends for the plot.
23pub fn draw_legend(
24    scales: &ScaleSet,
25    theme: &Theme,
26    plot_area: &Rect,
27    backend: &mut dyn DrawBackend,
28    guide: &GuideLegend,
29    suppressed: &std::collections::HashSet<Aesthetic>,
30) -> Result<(), RenderError> {
31    if matches!(theme.legend_position, LegendPosition::None) {
32        return Ok(());
33    }
34
35    // Collect all aesthetics that have a scale with breaks
36    let mut legend_scales: Vec<&Aesthetic> = Vec::new();
37    for aes in LEGEND_AESTHETICS {
38        // Skip suppressed aesthetics
39        if suppressed.contains(aes) {
40            continue;
41        }
42        if let Some(scale) = scales.get(aes) {
43            if !scale.breaks().is_empty() {
44                // Don't duplicate Color/Fill if both exist with same breaks
45                if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
46                    continue;
47                }
48                legend_scales.push(aes);
49            }
50        }
51    }
52
53    if legend_scales.is_empty() {
54        return Ok(());
55    }
56
57    // Compute legend origin based on position
58    let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
59    // legend.direction overrides the auto layout from the position.
60    if let Some(dir) = theme.legend_direction {
61        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
62    }
63
64    let mut offset_y = legend_y;
65    let mut offset_x = legend_x;
66
67    for aes in &legend_scales {
68        let scale = scales.get(aes).unwrap();
69
70        if scale.is_discrete() {
71            if is_horizontal {
72                let width = draw_discrete_legend_at(
73                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
74                )?;
75                offset_x += width + theme.legend_spacing * 2.0;
76            } else {
77                let height = draw_discrete_legend_at(
78                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
79                )?;
80                offset_y += height + theme.legend_spacing * 2.0;
81            }
82        } else {
83            // Continuous legend (colorbar) — only for color/fill
84            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
85                let height =
86                    draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
87                if is_horizontal {
88                    offset_x += theme.legend_key_width
89                        + theme.legend_text.size * 6.0
90                        + theme.legend_spacing * 2.0;
91                } else {
92                    offset_y += height + theme.legend_spacing * 2.0;
93                }
94            } else {
95                // Continuous size/alpha — draw as discrete-like with sampled breaks
96                let height = draw_discrete_legend_at(
97                    scales,
98                    aes,
99                    scale,
100                    theme,
101                    offset_x,
102                    offset_y,
103                    backend,
104                    guide,
105                    is_horizontal,
106                )?;
107                if is_horizontal {
108                    offset_x += theme.legend_key_width
109                        + theme.legend_text.size * 6.0
110                        + theme.legend_spacing * 2.0;
111                } else {
112                    offset_y += height + theme.legend_spacing * 2.0;
113                }
114            }
115        }
116    }
117
118    Ok(())
119}
120
121/// Compute legend origin based on position setting.
122/// Returns (x, y, is_horizontal).
123fn legend_position(theme: &Theme, plot_area: &Rect) -> (f64, f64, bool) {
124    match theme.legend_position {
125        LegendPosition::Right => (
126            plot_area.x + plot_area.width + theme.legend_margin.left,
127            plot_area.y + theme.legend_margin.top,
128            false,
129        ),
130        LegendPosition::Left => (
131            theme.legend_margin.left,
132            plot_area.y + theme.legend_margin.top,
133            false,
134        ),
135        LegendPosition::Top => (
136            plot_area.x + theme.legend_margin.left,
137            theme.legend_margin.top,
138            true,
139        ),
140        LegendPosition::Bottom => (
141            plot_area.x + theme.legend_margin.left,
142            plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
143            true,
144        ),
145        LegendPosition::None => (0.0, 0.0, false),
146        LegendPosition::Inside(fx, fy) => (
147            plot_area.x + fx * plot_area.width,
148            plot_area.y + (1.0 - fy) * plot_area.height,
149            false,
150        ),
151    }
152}
153
154/// Draw a discrete legend at a given position. Returns the height used.
155#[allow(clippy::too_many_arguments)]
156fn draw_discrete_legend_at(
157    scales: &ScaleSet,
158    aes: &Aesthetic,
159    scale: &dyn crate::scale::Scale,
160    theme: &Theme,
161    legend_x: f64,
162    legend_y: f64,
163    backend: &mut dyn DrawBackend,
164    guide: &GuideLegend,
165    is_horizontal: bool,
166) -> Result<f64, RenderError> {
167    let mut breaks = scale.breaks();
168    if breaks.is_empty() {
169        return Ok(0.0);
170    }
171
172    // Apply guide reverse
173    if guide.reverse {
174        breaks.reverse();
175    }
176
177    let item_height = theme.legend_key_height;
178    let swatch_size = theme.legend_key_width;
179
180    // Draw legend title (guide title overrides scale name)
181    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
182    let legend_family = if theme.legend_title.family.is_empty() {
183        None
184    } else {
185        Some(theme.legend_title.family.clone())
186    };
187    let title_offset = if !title.is_empty() {
188        backend.draw_text(
189            title,
190            (legend_x, legend_y),
191            &TextStyle {
192                color: theme.legend_title.color,
193                size: theme.legend_title.size,
194                anchor: TextAnchor::Start,
195                angle: 0.0,
196                family: legend_family,
197                face: theme.legend_title.face,
198            },
199        )?;
200        theme.legend_title.size + 4.0
201    } else {
202        0.0
203    };
204
205    let items_y = legend_y + title_offset;
206
207    // Draw legend background
208    if theme.legend_background.visible {
209        let total_height = breaks.len() as f64 * item_height;
210        let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
211        if let Some(fill) = theme.legend_background.fill {
212            backend.draw_rect(
213                (legend_x - 2.0, items_y - 2.0),
214                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
215                &RectStyle {
216                    fill: Some(fill),
217                    stroke: theme.legend_background.color,
218                    stroke_width: theme.legend_background.width,
219                    alpha: 1.0,
220                    clip: false,
221                },
222            )?;
223        }
224    }
225
226    // Horizontal legends lay keys left-to-right; vertical ones stack downward.
227    let mut cur_x = legend_x;
228    for (i, (pos, label)) in breaks.iter().enumerate() {
229        let x = if is_horizontal { cur_x } else { legend_x };
230        let y = if is_horizontal {
231            items_y
232        } else {
233            items_y + i as f64 * item_height
234        };
235        let center_x = x + swatch_size / 2.0;
236        let center_y = y + swatch_size / 2.0;
237
238        // Draw legend key background
239        if theme.legend_key.visible {
240            if let Some(fill) = theme.legend_key.fill {
241                backend.draw_rect(
242                    (x, y),
243                    (x + swatch_size, y + swatch_size),
244                    &RectStyle {
245                        fill: Some(fill),
246                        stroke: theme.legend_key.color,
247                        stroke_width: theme.legend_key.width,
248                        alpha: 1.0,
249                        clip: false,
250                    },
251                )?;
252            }
253        }
254
255        // Draw the appropriate swatch based on aesthetic type
256        let value = Value::Str(label.clone());
257        match aes {
258            Aesthetic::Color | Aesthetic::Fill => {
259                let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
260                backend.draw_rect(
261                    (x, y),
262                    (x + swatch_size, y + swatch_size),
263                    &RectStyle {
264                        fill: Some(color),
265                        stroke: None,
266                        stroke_width: 0.0,
267                        alpha: 1.0,
268                        clip: false,
269                    },
270                )?;
271            }
272            Aesthetic::Shape => {
273                let shape = scales
274                    .map_shape(&value)
275                    .unwrap_or(crate::render::backend::PointShape::Circle);
276                backend.draw_shape(
277                    (center_x, center_y),
278                    swatch_size / 3.0,
279                    &PointStyle {
280                        color: (50, 50, 50),
281                        alpha: 1.0,
282                        filled: true,
283                        shape,
284                    },
285                )?;
286            }
287            Aesthetic::Linetype => {
288                let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
289                backend.draw_line(
290                    &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
291                    &LineStyle {
292                        color: (50, 50, 50),
293                        width: 1.5,
294                        alpha: 1.0,
295                        linetype: lt,
296                        ..Default::default()
297                    },
298                )?;
299            }
300            Aesthetic::Size => {
301                // For size, show varying circle sizes
302                let size = scales.map_size(&value).unwrap_or(3.0);
303                backend.draw_shape(
304                    (center_x, center_y),
305                    size.min(swatch_size / 2.0),
306                    &PointStyle {
307                        color: (50, 50, 50),
308                        alpha: 1.0,
309                        filled: true,
310                        shape: crate::render::backend::PointShape::Circle,
311                    },
312                )?;
313            }
314            Aesthetic::Alpha => {
315                let alpha = scales.map_alpha(&value).unwrap_or(1.0);
316                backend.draw_rect(
317                    (x, y),
318                    (x + swatch_size, y + swatch_size),
319                    &RectStyle {
320                        fill: Some((50, 50, 50)),
321                        stroke: None,
322                        stroke_width: 0.0,
323                        alpha,
324                        clip: false,
325                    },
326                )?;
327            }
328            Aesthetic::Linewidth => {
329                // A horizontal line of the mapped width, capped at the key
330                // height. Uses the break's own [0,1] fraction (not a
331                // round-trip through the label text — see
332                // `Scale::map_fraction_to_size`).
333                let width = scale.map_fraction_to_size(*pos).unwrap_or(1.0);
334                backend.draw_line(
335                    &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
336                    &LineStyle {
337                        color: (50, 50, 50),
338                        width: width.min(swatch_size),
339                        alpha: 1.0,
340                        linetype: Linetype::Solid,
341                        ..Default::default()
342                    },
343                )?;
344            }
345            _ => {}
346        }
347
348        // Label
349        let label_family = if theme.legend_text.family.is_empty() {
350            None
351        } else {
352            Some(theme.legend_text.family.clone())
353        };
354        backend.draw_text(
355            label,
356            (x + swatch_size + theme.legend_spacing, center_y),
357            &TextStyle {
358                color: theme.legend_text.color,
359                size: theme.legend_text.size,
360                anchor: TextAnchor::Start,
361                angle: 0.0,
362                family: label_family,
363                face: theme.legend_text.face,
364            },
365        )?;
366        if is_horizontal {
367            // advance past swatch + spacing + estimated label width + gap
368            cur_x = x
369                + swatch_size
370                + theme.legend_spacing
371                + (label.chars().count() as f64) * theme.legend_text.size * 0.62
372                + theme.legend_spacing * 3.0;
373        }
374    }
375
376    if is_horizontal {
377        Ok(cur_x - legend_x) // width used
378    } else {
379        Ok(title_offset + breaks.len() as f64 * item_height)
380    }
381}
382
383/// Draw a continuous colorbar legend at a given position. Returns the height used.
384fn draw_continuous_legend_at(
385    scale: &dyn crate::scale::Scale,
386    theme: &Theme,
387    legend_x: f64,
388    legend_y: f64,
389    backend: &mut dyn DrawBackend,
390    guide: &GuideLegend,
391) -> Result<f64, RenderError> {
392    let breaks = scale.breaks();
393    if breaks.is_empty() {
394        return Ok(0.0);
395    }
396
397    let bar_width = theme.legend_key_width;
398    let bar_height = theme.legend_key_height * 8.0;
399
400    // Draw legend title (guide title overrides scale name)
401    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
402    let cont_family = if theme.legend_title.family.is_empty() {
403        None
404    } else {
405        Some(theme.legend_title.family.clone())
406    };
407    let title_offset = if !title.is_empty() {
408        backend.draw_text(
409            title,
410            (legend_x, legend_y),
411            &TextStyle {
412                color: theme.legend_title.color,
413                size: theme.legend_title.size,
414                anchor: TextAnchor::Start,
415                angle: 0.0,
416                family: cont_family,
417                face: theme.legend_title.face,
418            },
419        )?;
420        theme.legend_title.size + 4.0
421    } else {
422        0.0
423    };
424
425    let bar_top = legend_y + title_offset;
426
427    // Draw legend background
428    if theme.legend_background.visible {
429        let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
430        if let Some(fill) = theme.legend_background.fill {
431            backend.draw_rect(
432                (legend_x - 2.0, bar_top - 2.0),
433                (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
434                &RectStyle {
435                    fill: Some(fill),
436                    stroke: theme.legend_background.color,
437                    stroke_width: theme.legend_background.width,
438                    alpha: 1.0,
439                    clip: false,
440                },
441            )?;
442        }
443    }
444
445    // Draw gradient bar as N thin horizontal slices
446    // Use data-domain values to avoid double-normalization in map_to_color()
447    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
448    let n_slices = 50;
449    let slice_height = bar_height / n_slices as f64;
450    for i in 0..n_slices {
451        let t = 1.0 - i as f64 / n_slices as f64;
452        let data_val = data_min + t * (data_max - data_min);
453        let color = scale
454            .map_to_color(&Value::Float(data_val))
455            .unwrap_or((127, 127, 127));
456        let sy = bar_top + i as f64 * slice_height;
457        backend.draw_rect(
458            (legend_x, sy),
459            (legend_x + bar_width, sy + slice_height + 0.5),
460            &RectStyle {
461                fill: Some(color),
462                stroke: None,
463                stroke_width: 0.0,
464                alpha: 1.0,
465                clip: false,
466            },
467        )?;
468    }
469
470    // Draw border
471    // (Ticks reuse its colour and width even when the frame is hidden.)
472    let border = theme.legend_bar_border_line();
473    let border_style = LineStyle {
474        color: border.color,
475        width: border.width,
476        alpha: 1.0,
477        linetype: border.linetype,
478        ..Default::default()
479    };
480    if border.visible {
481        backend.draw_line(
482            &[
483                (legend_x, bar_top),
484                (legend_x + bar_width, bar_top),
485                (legend_x + bar_width, bar_top + bar_height),
486                (legend_x, bar_top + bar_height),
487                (legend_x, bar_top),
488            ],
489            &border_style,
490        )?;
491    }
492
493    // Draw tick marks and labels
494    let tick_len = 3.0;
495    for (pos, label) in &breaks {
496        let tick_y = bar_top + bar_height * (1.0 - pos);
497        backend.draw_line(
498            &[
499                (legend_x + bar_width, tick_y),
500                (legend_x + bar_width + tick_len, tick_y),
501            ],
502            &border_style,
503        )?;
504        let tick_family = if theme.legend_text.family.is_empty() {
505            None
506        } else {
507            Some(theme.legend_text.family.clone())
508        };
509        backend.draw_text(
510            label,
511            (
512                legend_x + bar_width + tick_len + theme.legend_spacing,
513                tick_y,
514            ),
515            &TextStyle {
516                color: theme.legend_text.color,
517                size: theme.legend_text.size,
518                anchor: TextAnchor::Start,
519                angle: 0.0,
520                family: tick_family,
521                face: theme.legend_text.face,
522            },
523        )?;
524    }
525
526    Ok(title_offset + bar_height)
527}