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