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/// The aesthetics that would get a legend, in the same order and with the
23/// same color/fill de-duplication `draw_legend` and `draw_legend_planned`
24/// use. Shared with `layout_built` (B3-2), so the "is this the single
25/// discrete guide D7 requires" check is the same one the drawing code
26/// uses.
27pub(crate) fn legend_scales_for(
28    scales: &ScaleSet,
29    suppressed: &std::collections::HashSet<Aesthetic>,
30) -> Vec<Aesthetic> {
31    let mut legend_scales: Vec<Aesthetic> = Vec::new();
32    for aes in LEGEND_AESTHETICS {
33        if suppressed.contains(aes) {
34            continue;
35        }
36        if let Some(scale) = scales.get(aes) {
37            if !scale.breaks().is_empty() {
38                if *aes == Aesthetic::Fill && legend_scales.contains(&Aesthetic::Color) {
39                    continue;
40                }
41                legend_scales.push(aes.clone());
42            }
43        }
44    }
45    legend_scales
46}
47
48/// Draw all legends for the plot.
49///
50/// No longer called internally: `compose::capture_legend` (the only
51/// caller) was rerouted to `draw_legend_planned` in 0.23 so a `PlotGrid`'s
52/// collected legend gets the same B3-2 wrap/grid treatment a single
53/// plot's legend does (sipemu/ggplot-rs#155). `draw_legend_planned` is
54/// crate-internal (`pub(crate)`), so there is no direct public
55/// replacement — legend drawing is otherwise handled automatically by
56/// `GGPlot`'s own render methods, which is the supported way to draw a
57/// legend; this function was never meant to be called by consumers for
58/// that purpose and will be removed in a later minor release.
59#[deprecated(
60    since = "0.23.0",
61    note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
62             sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
63             by GGPlot's render methods. Will be removed in a later minor release."
64)]
65pub fn draw_legend(
66    scales: &ScaleSet,
67    theme: &Theme,
68    plot_area: &Rect,
69    backend: &mut dyn DrawBackend,
70    guide: &GuideLegend,
71    suppressed: &std::collections::HashSet<Aesthetic>,
72) -> Result<(), RenderError> {
73    if matches!(theme.legend_position, LegendPosition::None) {
74        return Ok(());
75    }
76
77    // Collect all aesthetics that have a scale with breaks
78    let mut legend_scales: Vec<&Aesthetic> = Vec::new();
79    for aes in LEGEND_AESTHETICS {
80        // Skip suppressed aesthetics
81        if suppressed.contains(aes) {
82            continue;
83        }
84        if let Some(scale) = scales.get(aes) {
85            if !scale.breaks().is_empty() {
86                // Don't duplicate Color/Fill if both exist with same breaks
87                if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
88                    continue;
89                }
90                legend_scales.push(aes);
91            }
92        }
93    }
94
95    if legend_scales.is_empty() {
96        return Ok(());
97    }
98
99    // Compute legend origin based on position
100    let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
101    // legend.direction overrides the auto layout from the position.
102    if let Some(dir) = theme.legend_direction {
103        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
104    }
105
106    let mut offset_y = legend_y;
107    let mut offset_x = legend_x;
108
109    for aes in &legend_scales {
110        let scale = scales.get(aes).unwrap();
111
112        if scale.is_discrete() {
113            if is_horizontal {
114                let width = draw_discrete_legend_at(
115                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
116                )?;
117                offset_x += width + theme.legend_spacing * 2.0;
118            } else {
119                let height = draw_discrete_legend_at(
120                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
121                )?;
122                offset_y += height + theme.legend_spacing * 2.0;
123            }
124        } else {
125            // Continuous legend (colorbar) — only for color/fill
126            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
127                let height =
128                    draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
129                if is_horizontal {
130                    offset_x += theme.legend_key_width
131                        + theme.legend_text.size * 6.0
132                        + theme.legend_spacing * 2.0;
133                } else {
134                    offset_y += height + theme.legend_spacing * 2.0;
135                }
136            } else {
137                // Continuous size/alpha — draw as discrete-like with sampled breaks
138                let height = draw_discrete_legend_at(
139                    scales,
140                    aes,
141                    scale,
142                    theme,
143                    offset_x,
144                    offset_y,
145                    backend,
146                    guide,
147                    is_horizontal,
148                )?;
149                if is_horizontal {
150                    offset_x += theme.legend_key_width
151                        + theme.legend_text.size * 6.0
152                        + theme.legend_spacing * 2.0;
153                } else {
154                    offset_y += height + theme.legend_spacing * 2.0;
155                }
156            }
157        }
158    }
159
160    Ok(())
161}
162
163/// Compute legend origin based on position setting.
164/// Returns (x, y, is_horizontal).
165pub(crate) fn legend_position(theme: &Theme, plot_area: &Rect) -> (f64, f64, bool) {
166    match theme.legend_position {
167        LegendPosition::Right => (
168            plot_area.x + plot_area.width + theme.legend_margin.left,
169            plot_area.y + theme.legend_margin.top,
170            false,
171        ),
172        LegendPosition::Left => (
173            theme.legend_margin.left,
174            plot_area.y + theme.legend_margin.top,
175            false,
176        ),
177        LegendPosition::Top => (
178            plot_area.x + theme.legend_margin.left,
179            theme.legend_margin.top,
180            true,
181        ),
182        LegendPosition::Bottom => (
183            plot_area.x + theme.legend_margin.left,
184            plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
185            true,
186        ),
187        LegendPosition::None => (0.0, 0.0, false),
188        LegendPosition::Inside(fx, fy) => (
189            plot_area.x + fx * plot_area.width,
190            plot_area.y + (1.0 - fy) * plot_area.height,
191            false,
192        ),
193    }
194}
195
196/// Draw a discrete legend at a given position. Returns the height used.
197#[allow(clippy::too_many_arguments)]
198fn draw_discrete_legend_at(
199    scales: &ScaleSet,
200    aes: &Aesthetic,
201    scale: &dyn crate::scale::Scale,
202    theme: &Theme,
203    legend_x: f64,
204    legend_y: f64,
205    backend: &mut dyn DrawBackend,
206    guide: &GuideLegend,
207    is_horizontal: bool,
208) -> Result<f64, RenderError> {
209    let mut breaks = scale.breaks();
210    if breaks.is_empty() {
211        return Ok(0.0);
212    }
213
214    // Apply guide reverse
215    if guide.reverse {
216        breaks.reverse();
217    }
218
219    let item_height = theme.legend_key_height;
220    let swatch_size = theme.legend_key_width;
221
222    // Draw legend title (guide title overrides scale name)
223    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
224    let legend_family = if theme.legend_title.family.is_empty() {
225        None
226    } else {
227        Some(theme.legend_title.family.clone())
228    };
229    let title_offset = if !title.is_empty() {
230        backend.draw_text(
231            title,
232            (legend_x, legend_y),
233            &TextStyle {
234                color: theme.legend_title.color,
235                size: theme.legend_title.size,
236                anchor: TextAnchor::Start,
237                angle: 0.0,
238                family: legend_family,
239                face: theme.legend_title.face,
240            },
241        )?;
242        theme.legend_title.size + 4.0
243    } else {
244        0.0
245    };
246
247    let items_y = legend_y + title_offset;
248
249    // Draw legend background
250    if theme.legend_background.visible {
251        let total_height = breaks.len() as f64 * item_height;
252        let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
253        if let Some(fill) = theme.legend_background.fill {
254            backend.draw_rect(
255                (legend_x - 2.0, items_y - 2.0),
256                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
257                &RectStyle {
258                    fill: Some(fill),
259                    stroke: theme.legend_background.color,
260                    stroke_width: theme.legend_background.width,
261                    alpha: 1.0,
262                    clip: false,
263                    corner_radius: 0.0,
264                },
265            )?;
266        }
267    }
268
269    // Horizontal legends lay keys left-to-right; vertical ones stack downward.
270    let mut cur_x = legend_x;
271    for (i, (pos, label)) in breaks.iter().enumerate() {
272        let x = if is_horizontal { cur_x } else { legend_x };
273        let y = if is_horizontal {
274            items_y
275        } else {
276            items_y + i as f64 * item_height
277        };
278        let center_x = x + swatch_size / 2.0;
279        let center_y = y + swatch_size / 2.0;
280
281        // Draw legend key background
282        if theme.legend_key.visible {
283            if let Some(fill) = theme.legend_key.fill {
284                backend.draw_rect(
285                    (x, y),
286                    (x + swatch_size, y + swatch_size),
287                    &RectStyle {
288                        fill: Some(fill),
289                        stroke: theme.legend_key.color,
290                        stroke_width: theme.legend_key.width,
291                        alpha: 1.0,
292                        clip: false,
293                        corner_radius: 0.0,
294                    },
295                )?;
296            }
297        }
298
299        // Draw the appropriate swatch based on aesthetic type
300        draw_legend_swatch(
301            backend,
302            scales,
303            aes,
304            scale,
305            theme,
306            label,
307            *pos,
308            x,
309            y,
310            center_x,
311            center_y,
312            swatch_size,
313        )?;
314
315        // Label
316        let label_family = if theme.legend_text.family.is_empty() {
317            None
318        } else {
319            Some(theme.legend_text.family.clone())
320        };
321        backend.draw_text(
322            label,
323            (x + swatch_size + theme.legend_spacing, center_y),
324            &TextStyle {
325                color: theme.legend_text.color,
326                size: theme.legend_text.size,
327                anchor: TextAnchor::Start,
328                angle: 0.0,
329                family: label_family,
330                face: theme.legend_text.face,
331            },
332        )?;
333        if is_horizontal {
334            // advance past swatch + spacing + estimated label width + gap
335            cur_x = x
336                + swatch_size
337                + theme.legend_spacing
338                + (label.chars().count() as f64) * theme.legend_text.size * 0.62
339                + theme.legend_spacing * 3.0;
340        }
341    }
342
343    if is_horizontal {
344        Ok(cur_x - legend_x) // width used
345    } else {
346        Ok(title_offset + breaks.len() as f64 * item_height)
347    }
348}
349
350/// Draw a continuous colorbar legend at a given position. Returns the height used.
351fn draw_continuous_legend_at(
352    scale: &dyn crate::scale::Scale,
353    theme: &Theme,
354    legend_x: f64,
355    legend_y: f64,
356    backend: &mut dyn DrawBackend,
357    guide: &GuideLegend,
358) -> Result<f64, RenderError> {
359    let breaks = scale.breaks();
360    if breaks.is_empty() {
361        return Ok(0.0);
362    }
363
364    let bar_width = theme.legend_key_width;
365    let bar_height = theme.legend_key_height * 8.0;
366
367    // Draw legend title (guide title overrides scale name)
368    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
369    let cont_family = if theme.legend_title.family.is_empty() {
370        None
371    } else {
372        Some(theme.legend_title.family.clone())
373    };
374    let title_offset = if !title.is_empty() {
375        backend.draw_text(
376            title,
377            (legend_x, legend_y),
378            &TextStyle {
379                color: theme.legend_title.color,
380                size: theme.legend_title.size,
381                anchor: TextAnchor::Start,
382                angle: 0.0,
383                family: cont_family,
384                face: theme.legend_title.face,
385            },
386        )?;
387        theme.legend_title.size + 4.0
388    } else {
389        0.0
390    };
391
392    let bar_top = legend_y + title_offset;
393
394    // Draw legend background
395    if theme.legend_background.visible {
396        let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
397        if let Some(fill) = theme.legend_background.fill {
398            backend.draw_rect(
399                (legend_x - 2.0, bar_top - 2.0),
400                (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
401                &RectStyle {
402                    fill: Some(fill),
403                    stroke: theme.legend_background.color,
404                    stroke_width: theme.legend_background.width,
405                    alpha: 1.0,
406                    clip: false,
407                    corner_radius: 0.0,
408                },
409            )?;
410        }
411    }
412
413    // Draw gradient bar as N thin horizontal slices
414    // Use data-domain values to avoid double-normalization in map_to_color()
415    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
416    let n_slices = 50;
417    let slice_height = bar_height / n_slices as f64;
418    for i in 0..n_slices {
419        let t = 1.0 - i as f64 / n_slices as f64;
420        let data_val = data_min + t * (data_max - data_min);
421        let color = scale
422            .map_to_color(&Value::Float(data_val))
423            .unwrap_or((127, 127, 127));
424        let sy = bar_top + i as f64 * slice_height;
425        backend.draw_rect(
426            (legend_x, sy),
427            (legend_x + bar_width, sy + slice_height + 0.5),
428            &RectStyle {
429                fill: Some(color),
430                stroke: None,
431                stroke_width: 0.0,
432                alpha: 1.0,
433                clip: false,
434                corner_radius: 0.0,
435            },
436        )?;
437    }
438
439    // Draw border
440    // (Ticks reuse its colour and width even when the frame is hidden.)
441    let border = theme.legend_bar_border_line();
442    let border_style = LineStyle {
443        color: border.color,
444        width: border.width,
445        alpha: 1.0,
446        linetype: border.linetype,
447        ..Default::default()
448    };
449    if border.visible {
450        backend.draw_line(
451            &[
452                (legend_x, bar_top),
453                (legend_x + bar_width, bar_top),
454                (legend_x + bar_width, bar_top + bar_height),
455                (legend_x, bar_top + bar_height),
456                (legend_x, bar_top),
457            ],
458            &border_style,
459        )?;
460    }
461
462    // Draw tick marks and labels
463    let tick_len = 3.0;
464    for (pos, label) in &breaks {
465        let tick_y = bar_top + bar_height * (1.0 - pos);
466        backend.draw_line(
467            &[
468                (legend_x + bar_width, tick_y),
469                (legend_x + bar_width + tick_len, tick_y),
470            ],
471            &border_style,
472        )?;
473        let tick_family = if theme.legend_text.family.is_empty() {
474            None
475        } else {
476            Some(theme.legend_text.family.clone())
477        };
478        backend.draw_text(
479            label,
480            (
481                legend_x + bar_width + tick_len + theme.legend_spacing,
482                tick_y,
483            ),
484            &TextStyle {
485                color: theme.legend_text.color,
486                size: theme.legend_text.size,
487                anchor: TextAnchor::Start,
488                angle: 0.0,
489                family: tick_family,
490                face: theme.legend_text.face,
491            },
492        )?;
493    }
494
495    Ok(title_offset + bar_height)
496}
497
498/// Draw one legend key's swatch (not its label) for `aes`, at `(x, y)`
499/// (top-left) / `(center_x, center_y)` (centre), `swatch_size` square.
500/// Shared by the unchanged [`draw_discrete_legend_at`] and the B3-2
501/// planner-driven [`draw_legend_planned`], so both draw pixel-identical
502/// swatches.
503#[allow(clippy::too_many_arguments)]
504fn draw_legend_swatch(
505    backend: &mut dyn DrawBackend,
506    scales: &ScaleSet,
507    aes: &Aesthetic,
508    scale: &dyn crate::scale::Scale,
509    theme: &Theme,
510    label: &str,
511    pos: f64,
512    x: f64,
513    y: f64,
514    center_x: f64,
515    center_y: f64,
516    swatch_size: f64,
517) -> Result<(), RenderError> {
518    let value = Value::Str(label.to_string());
519    match aes {
520        Aesthetic::Color | Aesthetic::Fill => {
521            let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
522            backend.draw_rect(
523                (x, y),
524                (x + swatch_size, y + swatch_size),
525                &RectStyle {
526                    fill: Some(color),
527                    stroke: None,
528                    stroke_width: 0.0,
529                    alpha: 1.0,
530                    clip: false,
531                    corner_radius: 0.0,
532                },
533            )?;
534        }
535        Aesthetic::Shape => {
536            let shape = scales
537                .map_shape(&value)
538                .unwrap_or(crate::render::backend::PointShape::Circle);
539            backend.draw_shape(
540                (center_x, center_y),
541                swatch_size / 3.0,
542                &PointStyle {
543                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
544                    alpha: 1.0,
545                    filled: true,
546                    shape,
547                },
548            )?;
549        }
550        Aesthetic::Linetype => {
551            let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
552            backend.draw_line(
553                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
554                &LineStyle {
555                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
556                    width: 1.5,
557                    alpha: 1.0,
558                    linetype: lt,
559                    ..Default::default()
560                },
561            )?;
562        }
563        Aesthetic::Size => {
564            let size = scale
565                .map_fraction_to_size(pos)
566                .or_else(|| scales.map_size(&value))
567                .unwrap_or(3.0);
568            backend.draw_shape(
569                (center_x, center_y),
570                size.min(swatch_size / 2.0),
571                &PointStyle {
572                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
573                    alpha: 1.0,
574                    filled: true,
575                    shape: crate::render::backend::PointShape::Circle,
576                },
577            )?;
578        }
579        Aesthetic::Alpha => {
580            let alpha = scale
581                .map_fraction_to_alpha(pos)
582                .or_else(|| scales.map_alpha(&value))
583                .unwrap_or(1.0);
584            backend.draw_rect(
585                (x, y),
586                (x + swatch_size, y + swatch_size),
587                &RectStyle {
588                    fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
589                    stroke: None,
590                    stroke_width: 0.0,
591                    alpha,
592                    clip: false,
593                    corner_radius: 0.0,
594                },
595            )?;
596        }
597        Aesthetic::Linewidth => {
598            let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
599            backend.draw_line(
600                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
601                &LineStyle {
602                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
603                    width: width.min(swatch_size),
604                    alpha: 1.0,
605                    linetype: Linetype::Solid,
606                    ..Default::default()
607                },
608            )?;
609        }
610        _ => {}
611    }
612    Ok(())
613}
614
615// ─── B3-2: nrow/ncol/byrow, auto-wrap, "+N more" ───────────────────────────
616
617/// One legend key's grid placement, in the planner's own pixel space
618/// (relative to the legend's own origin — `row` is 0-indexed top row,
619/// `x_offset` is the horizontal pitch from the first column).
620#[derive(Clone, Debug)]
621pub(crate) struct LegendEntry {
622    pub break_idx: usize,
623    pub row: usize,
624    pub x_offset: f64,
625    /// A truncated-with-ellipsis display label (#159), set only when a
626    /// vertical multi-column grid still doesn't fit the canvas after
627    /// reducing its column count as far as it can go: the full label is
628    /// still available (e.g. for a `<title>` tooltip) via the scale's own
629    /// `breaks()[break_idx]`. `None` draws the break's label unchanged —
630    /// the common, byte-identical case.
631    pub label_override: Option<String>,
632}
633
634/// A trailing "+{k} more" marker when the legend doesn't have room for
635/// every key (D7). Not a mark (`draw_text` only — no swatch, no
636/// `data-geom`/class).
637#[derive(Clone, Debug)]
638pub(crate) struct LegendOverflow {
639    pub dropped: usize,
640    pub row: usize,
641    pub x_offset: f64,
642    pub text: String,
643}
644
645/// A non-default legend layout (B3-2): an explicit `nrow`/`ncol` grid, or
646/// an auto-wrapped horizontal legend. `None` from [`plan_legend`] means
647/// "nothing changes" — the caller keeps the 0.21 single-row/single-column
648/// path verbatim, so a plot that doesn't need this is byte-identical.
649#[derive(Clone, Debug)]
650pub(crate) struct LegendPlan {
651    pub rows: usize,
652    pub entries: Vec<LegendEntry>,
653    pub overflow: Option<LegendOverflow>,
654    /// Columns in an explicit grid (always 1 for the horizontal auto-wrap
655    /// path, which never adds columns). Review #158 finding 3: a vertical
656    /// legend with `ncol > 1` needs `(cols − 1) · col_pitch` of extra
657    /// width reserved, the same way extra rows are reserved for height.
658    pub cols: usize,
659    /// The per-column horizontal pitch an explicit grid uses (0 for the
660    /// auto-wrap path, which doesn't have columns in this sense).
661    pub col_pitch: f64,
662}
663
664/// Per-key horizontal pitch (swatch + spacing + label + gap): the larger of
665/// today's crude per-character advance (`draw_discrete_legend_at`'s
666/// `cur_x` formula) and a `text_width`-based measurement, so a label that
667/// ties under both estimates stays on the byte-identical single-row path.
668fn legend_pitch(label: &str, theme: &Theme) -> f64 {
669    let swatch = theme.legend_key_width;
670    let spacing = theme.legend_spacing;
671    let text_size = theme.legend_text.size;
672    let legacy =
673        swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
674    let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
675    legacy.max(measured)
676}
677
678/// Plan a non-default legend layout (B3-2, D7). `avail_width` is the
679/// horizontal room for a horizontal legend's flow-pack / overflow check
680/// (`plot_area.x + plot_area.width - legend_x`); `avail_height` only feeds
681/// the default `max_rows` formula. Returns `None` when nothing changes:
682/// no explicit `nrow`/`ncol`, and (for a horizontal legend) the one-line
683/// layout already fits — or the legend is vertical, where auto-wrap never
684/// applies (D7 is horizontal-only; `nrow`/`ncol` still work on either).
685pub(crate) fn plan_legend(
686    breaks: &[(f64, String)],
687    guide: &GuideLegend,
688    theme: &Theme,
689    avail_width: f64,
690    avail_height: f64,
691    is_horizontal: bool,
692) -> Option<LegendPlan> {
693    let n = breaks.len();
694    if n == 0 {
695        return None;
696    }
697
698    // Explicit grid: ggplot2's `nrow`/`ncol`, column-major unless `byrow`.
699    if guide.nrow.is_some() || guide.ncol.is_some() {
700        let (rows, cols) = match (guide.nrow, guide.ncol) {
701            (Some(nrow), Some(ncol)) => (nrow, ncol),
702            (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
703            (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
704            (None, None) => unreachable!("checked above"),
705        };
706        let rows = rows.max(1);
707        let cols = cols.max(1);
708        let col_pitch = breaks
709            .iter()
710            .map(|(_, l)| legend_pitch(l, theme))
711            .fold(0.0_f64, f64::max);
712        let entries = (0..n)
713            .map(|i| {
714                let (row, col) = if guide.byrow {
715                    (i / cols, i % cols)
716                } else {
717                    (i % rows, i / rows)
718                };
719                LegendEntry {
720                    break_idx: i,
721                    row,
722                    x_offset: col as f64 * col_pitch,
723                    label_override: None,
724                }
725            })
726            .collect();
727        return Some(LegendPlan {
728            rows,
729            entries,
730            overflow: None,
731            cols,
732            col_pitch,
733        });
734    }
735
736    // Auto mode: D7 scopes auto-wrap to horizontal legends only.
737    if !is_horizontal {
738        return None;
739    }
740
741    let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
742    let total_width: f64 = pitches.iter().sum();
743    if total_width <= avail_width.max(0.0) {
744        return None; // fits on one line — byte-identical to 0.21.
745    }
746
747    let item_height = theme.legend_key_height.max(1.0);
748    let row_limit = guide
749        .max_rows
750        .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
751        .max(1);
752
753    let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
754    let mut row = 0usize;
755    let mut cur_x = 0.0_f64;
756    for (i, &p) in pitches.iter().enumerate() {
757        if cur_x > 0.0 && cur_x + p > avail_width {
758            if row + 1 >= row_limit {
759                break; // out of rows — the rest overflow into "+N more".
760            }
761            row += 1;
762            cur_x = 0.0;
763        }
764        entries.push(LegendEntry {
765            break_idx: i,
766            row,
767            x_offset: cur_x,
768            label_override: None,
769        });
770        cur_x += p;
771    }
772
773    if entries.len() >= n {
774        return Some(LegendPlan {
775            rows: row + 1,
776            entries,
777            overflow: None,
778            cols: 1,
779            col_pitch: 0.0,
780        });
781    }
782
783    // Overflow: make room for "+{k} more" on the last row, dropping
784    // entries from its tail until it fits (or the row is empty).
785    let mut dropped = n - entries.len();
786    loop {
787        let text = format!("+{dropped} more");
788        let more_pitch = legend_pitch(&text, theme);
789        let row_count = entries.iter().filter(|e| e.row == row).count();
790        let last_x = entries
791            .iter()
792            .filter(|e| e.row == row)
793            .map(|e| e.x_offset + pitches[e.break_idx])
794            .fold(0.0_f64, f64::max);
795        if last_x + more_pitch <= avail_width || row_count == 0 {
796            return Some(LegendPlan {
797                rows: row + 1,
798                entries,
799                overflow: Some(LegendOverflow {
800                    dropped,
801                    row,
802                    x_offset: last_x,
803                    text,
804                }),
805                cols: 1,
806                col_pitch: 0.0,
807            });
808        }
809        entries.pop();
810        dropped += 1;
811    }
812}
813
814/// How many of a plot's legend scales are discrete (the ones B3-2's
815/// wrap/grid applies to) — used to split the available horizontal room
816/// among them when several sit side by side (sipemu/ggplot-rs#155).
817fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
818    legend_scales
819        .iter()
820        .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
821        .count()
822        .max(1)
823}
824
825/// How many of a plot's legend scales are continuous (a colourbar, or a
826/// sampled continuous size/alpha legend) — each reserves its own fixed
827/// width share of a horizontal row (#170 / review #167 finding 7: a
828/// bottom legend mixing a colourbar with a discrete guide ignored the
829/// colourbar's share, so the discrete guide's auto-wrap/grid was planned
830/// against the *whole* row and could still run off the right edge).
831fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
832    legend_scales
833        .iter()
834        .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
835        .count()
836}
837
838/// The fixed horizontal width one continuous guide reserves in a
839/// horizontal row — the same per-guide step `draw_legend_planned` already
840/// advances `offset_x` by once it has drawn one (see its two
841/// `is_horizontal` arms for a continuous scale).
842fn continuous_guide_width(theme: &Theme) -> f64 {
843    theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
844}
845
846/// Plan every discrete legend scale's B3-2 layout (explicit `nrow`/`ncol`
847/// grid, or auto-wrap) independently (sipemu/ggplot-rs#155): previously
848/// this only ran for the plot's single discrete guide, leaving a second
849/// simultaneous discrete guide (e.g. both `colour` and `shape` mapped to
850/// discrete variables) always on the unwrapped 0.21 path. When several
851/// discrete guides sit side by side (`is_horizontal`), the available
852/// width is split evenly among them first, so each one wraps against its
853/// own share rather than the whole row (which would let it claim more
854/// room than its neighbours leave). Guides that stack vertically instead
855/// each get the full `avail_width`, since they don't compete for it.
856/// Returns one entry per discrete guide that needs anything beyond the
857/// single-row/single-column path — a guide absent from the map is
858/// byte-identical to 0.21/0.22.
859#[allow(clippy::too_many_arguments)]
860pub(crate) fn plan_all_legends(
861    scales: &ScaleSet,
862    guide: &GuideLegend,
863    theme: &Theme,
864    suppressed: &std::collections::HashSet<Aesthetic>,
865    avail_width: f64,
866    avail_height: f64,
867    is_horizontal: bool,
868    warnings: &mut Vec<String>,
869) -> std::collections::HashMap<Aesthetic, LegendPlan> {
870    let legend_scales = legend_scales_for(scales, suppressed);
871    let n_discrete = discrete_guide_count(scales, &legend_scales);
872    let per_guide_width = if is_horizontal {
873        // #170: leave the discrete guides only the width left over after
874        // every continuous guide's own fixed share (a colourbar sitting
875        // in the same row) — previously `avail_width` was split among
876        // the discrete guides alone, so a colourbar plus a discrete
877        // guide could together still overflow the row.
878        let n_continuous = continuous_guide_count(scales, &legend_scales);
879        let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
880        ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
881    } else {
882        avail_width
883    };
884
885    let mut plans = std::collections::HashMap::new();
886    for aes in &legend_scales {
887        let Some(scale) = scales.get(aes) else {
888            continue;
889        };
890        if !scale.is_discrete() {
891            continue;
892        }
893        let breaks = scale.breaks();
894        let validated_guide = guide.validated(breaks.len(), warnings);
895        if let Some(plan) = plan_legend(
896            &breaks,
897            &validated_guide,
898            theme,
899            per_guide_width,
900            avail_height,
901            is_horizontal,
902        ) {
903            plans.insert(aes.clone(), plan);
904        }
905    }
906    plans
907}
908
909/// Draw all legends for the plot, honouring each discrete guide's B3-2
910/// `plan_legend` layout (explicit `nrow`/`ncol`, or an overflowing
911/// horizontal legend's auto-wrap) when it has one — several simultaneous
912/// discrete guides each get their own independent plan
913/// (sipemu/ggplot-rs#155). A guide missing from `legend_plans` falls back
914/// to the unchanged [`draw_legend`] path — same calls, same order — which
915/// keeps this byte-identical to 0.21/0.22 for every plot nothing in
916/// `legend_plans` touches. Used by `renderer.rs` (single and faceted) and
917/// `compose::capture_legend` (PlotGrid-collected legends).
918#[allow(clippy::too_many_arguments)]
919pub(crate) fn draw_legend_planned(
920    scales: &ScaleSet,
921    theme: &Theme,
922    plot_area: &Rect,
923    // Review #167 finding 1: the width share each discrete guide was
924    // *planned* against (the same `avail_width` passed to
925    // `plan_all_legends`), not `plot_area.width`. For a single plot these
926    // are the same value in practice, but a PlotGrid's collected legend
927    // (`compose::capture_legend`) measures itself against a fake
928    // zero-width panel so its origin lands at (0, 0) — stepping by
929    // `plot_area.width / n` there always advanced by zero, so the second
930    // discrete guide (e.g. `shape` after `colour`) was drawn directly on
931    // top of the first instead of beside it.
932    legend_avail_width: f64,
933    backend: &mut dyn DrawBackend,
934    guide: &GuideLegend,
935    suppressed: &std::collections::HashSet<Aesthetic>,
936    // Review #158 finding 2: each discrete guide's plan, computed exactly
937    // once in `layout_built` (`BuiltPlot::legend_plans`) — drawn verbatim
938    // here rather than recomputed against this (possibly shorter, post-
939    // row-reservation) `plot_area`, so the drawn "+N more" always matches
940    // the warning that plan already produced.
941    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
942) -> Result<(), RenderError> {
943    if matches!(theme.legend_position, LegendPosition::None) {
944        return Ok(());
945    }
946
947    let legend_scales = legend_scales_for(scales, suppressed);
948    if legend_scales.is_empty() {
949        return Ok(());
950    }
951
952    let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
953    if let Some(dir) = theme.legend_direction {
954        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
955    }
956    let n_discrete = discrete_guide_count(scales, &legend_scales);
957    // #170: match `plan_all_legends`'s share — a continuous guide (e.g. a
958    // colourbar) sharing this row has already claimed its own fixed
959    // width, so step the discrete guides by what's left, not the whole
960    // `legend_avail_width`.
961    let n_continuous = continuous_guide_count(scales, &legend_scales);
962    let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
963    let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
964
965    let mut offset_y = legend_y;
966    let mut offset_x = legend_x;
967
968    for aes in &legend_scales {
969        let scale = scales.get(aes).unwrap();
970        if !scale.is_discrete() {
971            // Continuous legends (colorbar, or size/alpha sampled breaks)
972            // aren't in scope for B3-2 — unchanged.
973            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
974                let height =
975                    draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
976                if is_horizontal {
977                    offset_x += theme.legend_key_width
978                        + theme.legend_text.size * 6.0
979                        + theme.legend_spacing * 2.0;
980                } else {
981                    offset_y += height + theme.legend_spacing * 2.0;
982                }
983            } else {
984                let height = draw_discrete_legend_at(
985                    scales,
986                    aes,
987                    scale,
988                    theme,
989                    offset_x,
990                    offset_y,
991                    backend,
992                    guide,
993                    is_horizontal,
994                )?;
995                if is_horizontal {
996                    offset_x += theme.legend_key_width
997                        + theme.legend_text.size * 6.0
998                        + theme.legend_spacing * 2.0;
999                } else {
1000                    offset_y += height + theme.legend_spacing * 2.0;
1001                }
1002            }
1003            continue;
1004        }
1005
1006        match legend_plans.get(aes) {
1007            None => {
1008                if is_horizontal {
1009                    let width = draw_discrete_legend_at(
1010                        scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1011                    )?;
1012                    offset_x += width + theme.legend_spacing * 2.0;
1013                } else {
1014                    let height = draw_discrete_legend_at(
1015                        scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1016                    )?;
1017                    offset_y += height + theme.legend_spacing * 2.0;
1018                }
1019            }
1020            Some(plan) => {
1021                let height = draw_discrete_legend_grid(
1022                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1023                )?;
1024                if is_horizontal {
1025                    // Each discrete guide gets an equal share of the row
1026                    // (sipemu/ggplot-rs#155) rather than the whole row,
1027                    // which only held for exactly one discrete guide.
1028                    offset_x += per_guide_width;
1029                } else {
1030                    offset_y += height + theme.legend_spacing * 2.0;
1031                }
1032            }
1033        }
1034    }
1035
1036    Ok(())
1037}
1038
1039/// Draw a discrete legend using a [`LegendPlan`] (explicit grid or
1040/// auto-wrap). Returns the total height used (including any "+N more"
1041/// row).
1042#[allow(clippy::too_many_arguments)]
1043fn draw_discrete_legend_grid(
1044    scales: &ScaleSet,
1045    aes: &Aesthetic,
1046    scale: &dyn crate::scale::Scale,
1047    theme: &Theme,
1048    legend_x: f64,
1049    legend_y: f64,
1050    backend: &mut dyn DrawBackend,
1051    guide: &GuideLegend,
1052    plan: &LegendPlan,
1053) -> Result<f64, RenderError> {
1054    let mut breaks = scale.breaks();
1055    if breaks.is_empty() {
1056        return Ok(0.0);
1057    }
1058    if guide.reverse {
1059        breaks.reverse();
1060    }
1061
1062    let swatch_size = theme.legend_key_width;
1063    let item_height = theme.legend_key_height;
1064
1065    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1066    let legend_family = if theme.legend_title.family.is_empty() {
1067        None
1068    } else {
1069        Some(theme.legend_title.family.clone())
1070    };
1071    let title_offset = if !title.is_empty() {
1072        backend.draw_text(
1073            title,
1074            (legend_x, legend_y),
1075            &TextStyle {
1076                color: theme.legend_title.color,
1077                size: theme.legend_title.size,
1078                anchor: TextAnchor::Start,
1079                angle: 0.0,
1080                family: legend_family,
1081                face: theme.legend_title.face,
1082            },
1083        )?;
1084        theme.legend_title.size + 4.0
1085    } else {
1086        0.0
1087    };
1088    let items_y = legend_y + title_offset;
1089
1090    for entry in &plan.entries {
1091        let (pos, label) = &breaks[entry.break_idx];
1092        let x = legend_x + entry.x_offset;
1093        let y = items_y + entry.row as f64 * item_height;
1094        let center_x = x + swatch_size / 2.0;
1095        let center_y = y + swatch_size / 2.0;
1096
1097        if theme.legend_key.visible {
1098            if let Some(fill) = theme.legend_key.fill {
1099                backend.draw_rect(
1100                    (x, y),
1101                    (x + swatch_size, y + swatch_size),
1102                    &RectStyle {
1103                        fill: Some(fill),
1104                        stroke: theme.legend_key.color,
1105                        stroke_width: theme.legend_key.width,
1106                        alpha: 1.0,
1107                        clip: false,
1108                        corner_radius: 0.0,
1109                    },
1110                )?;
1111            }
1112        }
1113
1114        draw_legend_swatch(
1115            backend,
1116            scales,
1117            aes,
1118            scale,
1119            theme,
1120            label,
1121            *pos,
1122            x,
1123            y,
1124            center_x,
1125            center_y,
1126            swatch_size,
1127        )?;
1128
1129        let label_family = if theme.legend_text.family.is_empty() {
1130            None
1131        } else {
1132            Some(theme.legend_text.family.clone())
1133        };
1134        // #159: a label truncated to fit a vertical multi-column grid
1135        // still shows its full text via a `<title>` tooltip (backends
1136        // without tooltip support just render the truncated text).
1137        let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1138        backend.draw_text_with_title(
1139            display,
1140            (x + swatch_size + theme.legend_spacing, center_y),
1141            &TextStyle {
1142                color: theme.legend_text.color,
1143                size: theme.legend_text.size,
1144                anchor: TextAnchor::Start,
1145                angle: 0.0,
1146                family: label_family,
1147                face: theme.legend_text.face,
1148            },
1149            entry.label_override.as_deref().map(|_| label.as_str()),
1150        )?;
1151    }
1152
1153    // The overflow marker is text only — not a mark, no swatch.
1154    if let Some(ov) = &plan.overflow {
1155        let x = legend_x + ov.x_offset;
1156        let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1157        let label_family = if theme.legend_text.family.is_empty() {
1158            None
1159        } else {
1160            Some(theme.legend_text.family.clone())
1161        };
1162        backend.draw_text(
1163            &ov.text,
1164            (x, y),
1165            &TextStyle {
1166                color: theme.legend_text.color,
1167                size: theme.legend_text.size,
1168                anchor: TextAnchor::Start,
1169                angle: 0.0,
1170                family: label_family,
1171                face: theme.legend_text.face,
1172            },
1173        )?;
1174    }
1175
1176    Ok(title_offset + plan.rows as f64 * item_height)
1177}