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