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    }
134}
135
136/// Draw a discrete legend at a given position. Returns the height used.
137#[allow(clippy::too_many_arguments)]
138fn draw_discrete_legend_at(
139    scales: &ScaleSet,
140    aes: &Aesthetic,
141    scale: &dyn crate::scale::Scale,
142    theme: &Theme,
143    legend_x: f64,
144    legend_y: f64,
145    backend: &mut dyn DrawBackend,
146    guide: &GuideLegend,
147) -> Result<f64, RenderError> {
148    let mut breaks = scale.breaks();
149    if breaks.is_empty() {
150        return Ok(0.0);
151    }
152
153    // Apply guide reverse
154    if guide.reverse {
155        breaks.reverse();
156    }
157
158    let item_height = theme.legend_key_height;
159    let swatch_size = theme.legend_key_width;
160
161    // Draw legend title (guide title overrides scale name)
162    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
163    let legend_family = if theme.legend_title.family.is_empty() {
164        None
165    } else {
166        Some(theme.legend_title.family.clone())
167    };
168    let title_offset = if !title.is_empty() {
169        backend.draw_text(
170            title,
171            (legend_x, legend_y),
172            &TextStyle {
173                color: theme.legend_title.color,
174                size: theme.legend_title.size,
175                anchor: TextAnchor::Start,
176                angle: 0.0,
177                family: legend_family,
178            },
179        )?;
180        theme.legend_title.size + 4.0
181    } else {
182        0.0
183    };
184
185    let items_y = legend_y + title_offset;
186
187    // Draw legend background
188    if theme.legend_background.visible {
189        let total_height = breaks.len() as f64 * item_height;
190        let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
191        if let Some(fill) = theme.legend_background.fill {
192            backend.draw_rect(
193                (legend_x - 2.0, items_y - 2.0),
194                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
195                &RectStyle {
196                    fill: Some(fill),
197                    stroke: theme.legend_background.color,
198                    stroke_width: theme.legend_background.width,
199                    alpha: 1.0,
200                    clip: false,
201                },
202            )?;
203        }
204    }
205
206    for (i, (_, label)) in breaks.iter().enumerate() {
207        let y = items_y + i as f64 * item_height;
208        let center_x = legend_x + swatch_size / 2.0;
209        let center_y = y + swatch_size / 2.0;
210
211        // Draw legend key background
212        if theme.legend_key.visible {
213            if let Some(fill) = theme.legend_key.fill {
214                backend.draw_rect(
215                    (legend_x, y),
216                    (legend_x + swatch_size, y + swatch_size),
217                    &RectStyle {
218                        fill: Some(fill),
219                        stroke: theme.legend_key.color,
220                        stroke_width: theme.legend_key.width,
221                        alpha: 1.0,
222                        clip: false,
223                    },
224                )?;
225            }
226        }
227
228        // Draw the appropriate swatch based on aesthetic type
229        let value = Value::Str(label.clone());
230        match aes {
231            Aesthetic::Color | Aesthetic::Fill => {
232                let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
233                backend.draw_rect(
234                    (legend_x, y),
235                    (legend_x + swatch_size, y + swatch_size),
236                    &RectStyle {
237                        fill: Some(color),
238                        stroke: None,
239                        stroke_width: 0.0,
240                        alpha: 1.0,
241                        clip: false,
242                    },
243                )?;
244            }
245            Aesthetic::Shape => {
246                let shape = scales
247                    .map_shape(&value)
248                    .unwrap_or(crate::render::backend::PointShape::Circle);
249                backend.draw_shape(
250                    (center_x, center_y),
251                    swatch_size / 3.0,
252                    &PointStyle {
253                        color: (50, 50, 50),
254                        alpha: 1.0,
255                        filled: true,
256                        shape,
257                    },
258                )?;
259            }
260            Aesthetic::Linetype => {
261                let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
262                backend.draw_line(
263                    &[
264                        (legend_x + 2.0, center_y),
265                        (legend_x + swatch_size - 2.0, center_y),
266                    ],
267                    &LineStyle {
268                        color: (50, 50, 50),
269                        width: 1.5,
270                        alpha: 1.0,
271                        linetype: lt,
272                    },
273                )?;
274            }
275            Aesthetic::Size => {
276                // For size, show varying circle sizes
277                let size = scales.map_size(&value).unwrap_or(3.0);
278                backend.draw_shape(
279                    (center_x, center_y),
280                    size.min(swatch_size / 2.0),
281                    &PointStyle {
282                        color: (50, 50, 50),
283                        alpha: 1.0,
284                        filled: true,
285                        shape: crate::render::backend::PointShape::Circle,
286                    },
287                )?;
288            }
289            Aesthetic::Alpha => {
290                let alpha = scales.map_alpha(&value).unwrap_or(1.0);
291                backend.draw_rect(
292                    (legend_x, y),
293                    (legend_x + swatch_size, y + swatch_size),
294                    &RectStyle {
295                        fill: Some((50, 50, 50)),
296                        stroke: None,
297                        stroke_width: 0.0,
298                        alpha,
299                        clip: false,
300                    },
301                )?;
302            }
303            _ => {}
304        }
305
306        // Label
307        let label_family = if theme.legend_text.family.is_empty() {
308            None
309        } else {
310            Some(theme.legend_text.family.clone())
311        };
312        backend.draw_text(
313            label,
314            (legend_x + swatch_size + theme.legend_spacing, center_y),
315            &TextStyle {
316                color: theme.legend_text.color,
317                size: theme.legend_text.size,
318                anchor: TextAnchor::Start,
319                angle: 0.0,
320                family: label_family,
321            },
322        )?;
323    }
324
325    Ok(title_offset + breaks.len() as f64 * item_height)
326}
327
328/// Draw a continuous colorbar legend at a given position. Returns the height used.
329fn draw_continuous_legend_at(
330    scale: &dyn crate::scale::Scale,
331    theme: &Theme,
332    legend_x: f64,
333    legend_y: f64,
334    backend: &mut dyn DrawBackend,
335    guide: &GuideLegend,
336) -> Result<f64, RenderError> {
337    let breaks = scale.breaks();
338    if breaks.is_empty() {
339        return Ok(0.0);
340    }
341
342    let bar_width = theme.legend_key_width;
343    let bar_height = theme.legend_key_height * 8.0;
344
345    // Draw legend title (guide title overrides scale name)
346    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
347    let cont_family = if theme.legend_title.family.is_empty() {
348        None
349    } else {
350        Some(theme.legend_title.family.clone())
351    };
352    let title_offset = if !title.is_empty() {
353        backend.draw_text(
354            title,
355            (legend_x, legend_y),
356            &TextStyle {
357                color: theme.legend_title.color,
358                size: theme.legend_title.size,
359                anchor: TextAnchor::Start,
360                angle: 0.0,
361                family: cont_family,
362            },
363        )?;
364        theme.legend_title.size + 4.0
365    } else {
366        0.0
367    };
368
369    let bar_top = legend_y + title_offset;
370
371    // Draw legend background
372    if theme.legend_background.visible {
373        let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
374        if let Some(fill) = theme.legend_background.fill {
375            backend.draw_rect(
376                (legend_x - 2.0, bar_top - 2.0),
377                (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
378                &RectStyle {
379                    fill: Some(fill),
380                    stroke: theme.legend_background.color,
381                    stroke_width: theme.legend_background.width,
382                    alpha: 1.0,
383                    clip: false,
384                },
385            )?;
386        }
387    }
388
389    // Draw gradient bar as N thin horizontal slices
390    // Use data-domain values to avoid double-normalization in map_to_color()
391    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
392    let n_slices = 50;
393    let slice_height = bar_height / n_slices as f64;
394    for i in 0..n_slices {
395        let t = 1.0 - i as f64 / n_slices as f64;
396        let data_val = data_min + t * (data_max - data_min);
397        let color = scale
398            .map_to_color(&Value::Float(data_val))
399            .unwrap_or((127, 127, 127));
400        let sy = bar_top + i as f64 * slice_height;
401        backend.draw_rect(
402            (legend_x, sy),
403            (legend_x + bar_width, sy + slice_height + 0.5),
404            &RectStyle {
405                fill: Some(color),
406                stroke: None,
407                stroke_width: 0.0,
408                alpha: 1.0,
409                clip: false,
410            },
411        )?;
412    }
413
414    // Draw border
415    let border_style = LineStyle {
416        color: theme.legend_key.color.unwrap_or((50, 50, 50)),
417        width: 0.5,
418        alpha: 1.0,
419        linetype: Linetype::Solid,
420    };
421    backend.draw_line(
422        &[
423            (legend_x, bar_top),
424            (legend_x + bar_width, bar_top),
425            (legend_x + bar_width, bar_top + bar_height),
426            (legend_x, bar_top + bar_height),
427            (legend_x, bar_top),
428        ],
429        &border_style,
430    )?;
431
432    // Draw tick marks and labels
433    let tick_len = 3.0;
434    for (pos, label) in &breaks {
435        let tick_y = bar_top + bar_height * (1.0 - pos);
436        backend.draw_line(
437            &[
438                (legend_x + bar_width, tick_y),
439                (legend_x + bar_width + tick_len, tick_y),
440            ],
441            &border_style,
442        )?;
443        let tick_family = if theme.legend_text.family.is_empty() {
444            None
445        } else {
446            Some(theme.legend_text.family.clone())
447        };
448        backend.draw_text(
449            label,
450            (
451                legend_x + bar_width + tick_len + theme.legend_spacing,
452                tick_y,
453            ),
454            &TextStyle {
455                color: theme.legend_text.color,
456                size: theme.legend_text.size,
457                anchor: TextAnchor::Start,
458                angle: 0.0,
459                family: tick_family,
460            },
461        )?;
462    }
463
464    Ok(title_offset + bar_height)
465}