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