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