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