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/// Whether `a` is one of [`LEGEND_AESTHETICS`] (#241: `has_legend_mapping`'s
23/// own `is_legend_aes` check, in `plot.rs`'s layout-building pass, had
24/// drifted into a second, incomplete copy of this list — missing
25/// `Linewidth`, so a plot mapping only `linewidth` reserved *no* legend
26/// space at all, and its legend then drew with nothing held back for it,
27/// running off the canvas). Delegating to this one list means the two
28/// checks — "should this aesthetic reserve layout space" and "should this
29/// aesthetic actually draw a legend" — can't drift apart again.
30pub(crate) fn is_legend_aesthetic(a: &Aesthetic) -> bool {
31    LEGEND_AESTHETICS.contains(a)
32}
33
34/// The aesthetics that would get a legend, in the same order and with the
35/// same color/fill de-duplication `draw_legend` and `draw_legend_planned`
36/// use. Shared with `layout_built` (B3-2), so the "is this the single
37/// discrete guide D7 requires" check is the same one the drawing code
38/// uses.
39pub(crate) fn legend_scales_for(
40    scales: &ScaleSet,
41    suppressed: &std::collections::HashSet<Aesthetic>,
42) -> Vec<Aesthetic> {
43    let mut legend_scales: Vec<Aesthetic> = Vec::new();
44    for aes in LEGEND_AESTHETICS {
45        if suppressed.contains(aes) {
46            continue;
47        }
48        if let Some(scale) = scales.get(aes) {
49            if !scale.breaks().is_empty() {
50                legend_scales.push(aes.clone());
51            }
52        }
53    }
54    legend_scales
55}
56
57/// Group `legend_scales` into mergeable sets (#239: ggplot2 merges `fill`
58/// and `colour` guides mapped to the same measure through the same scale
59/// spec into one legend with a combined key, rather than drawing one per
60/// aesthetic — and, conversely, draws *two* legends when they don't
61/// actually match, which an earlier, cruder rule here got wrong: it
62/// unconditionally dropped `Fill`'s own legend whenever `Color` was also
63/// mapped, even to an unrelated variable with a different title and
64/// breaks). Two aesthetics merge when their scales share the same
65/// effective title (`guide.title`, else `scale.name()`), the same
66/// discrete/continuous-ness, and *identical* `breaks()` (same positions
67/// and labels, in the same order) — the same practical "do these two
68/// guides look identical" test ggplot2 itself applies; a binned scale's
69/// breaks already carry its counts in their labels, so two binned scales
70/// with the same counts per bin merge too. Order preserved: each group
71/// keeps the position its first member had in `legend_scales`.
72pub(crate) fn group_mergeable_legends(
73    scales: &ScaleSet,
74    legend_scales: &[Aesthetic],
75    guide: &GuideLegend,
76) -> Vec<Vec<Aesthetic>> {
77    struct Key {
78        title: String,
79        discrete: bool,
80        breaks: Vec<(f64, String)>,
81    }
82    let mut groups: Vec<(Key, Vec<Aesthetic>)> = Vec::new();
83    for aes in legend_scales {
84        let Some(scale) = scales.get(aes) else {
85            continue;
86        };
87        let breaks = scale.breaks();
88        let title = guide
89            .title
90            .clone()
91            .unwrap_or_else(|| scale.name().to_string());
92        let discrete = scale.is_discrete();
93        match groups
94            .iter_mut()
95            .find(|(k, _)| k.title == title && k.discrete == discrete && k.breaks == breaks)
96        {
97            Some((_, members)) => members.push(aes.clone()),
98            None => groups.push((
99                Key {
100                    title,
101                    discrete,
102                    breaks,
103                },
104                vec![aes.clone()],
105            )),
106        }
107    }
108    groups.into_iter().map(|(_, members)| members).collect()
109}
110
111/// Draw all legends for the plot.
112///
113/// No longer called internally: `compose::capture_legend` (the only
114/// caller) was rerouted to `draw_legend_planned` in 0.23 so a `PlotGrid`'s
115/// collected legend gets the same B3-2 wrap/grid treatment a single
116/// plot's legend does (sipemu/ggplot-rs#155). `draw_legend_planned` is
117/// crate-internal (`pub(crate)`), so there is no direct public
118/// replacement — legend drawing is otherwise handled automatically by
119/// `GGPlot`'s own render methods, which is the supported way to draw a
120/// legend; this function was never meant to be called by consumers for
121/// that purpose and will be removed in a later minor release.
122#[deprecated(
123    since = "0.23.0",
124    note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
125             sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
126             by GGPlot's render methods. Will be removed in a later minor release."
127)]
128pub fn draw_legend(
129    scales: &ScaleSet,
130    theme: &Theme,
131    plot_area: &Rect,
132    backend: &mut dyn DrawBackend,
133    guide: &GuideLegend,
134    suppressed: &std::collections::HashSet<Aesthetic>,
135) -> Result<(), RenderError> {
136    if matches!(theme.legend_position, LegendPosition::None) {
137        return Ok(());
138    }
139
140    // Collect all aesthetics that have a scale with breaks
141    let mut legend_scales: Vec<&Aesthetic> = Vec::new();
142    for aes in LEGEND_AESTHETICS {
143        // Skip suppressed aesthetics
144        if suppressed.contains(aes) {
145            continue;
146        }
147        if let Some(scale) = scales.get(aes) {
148            if !scale.breaks().is_empty() {
149                // Don't duplicate Color/Fill if both exist with same breaks
150                if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
151                    continue;
152                }
153                legend_scales.push(aes);
154            }
155        }
156    }
157
158    if legend_scales.is_empty() {
159        return Ok(());
160    }
161
162    // Compute legend origin based on position. This deprecated path never
163    // wraps a legend onto more than one row, so `legend_extra_rows` is
164    // always 0 — see `legend_position`'s doc comment. It also predates
165    // #225's `Inside` justification and keeps the old top-left anchor
166    // (`legend_size_hint: (0.0, 0.0)`) rather than estimate it, since
167    // it's unused internally and has no direct public replacement to
168    // keep behaviourally current.
169    let (legend_x, legend_y, mut is_horizontal) =
170        legend_position(theme, plot_area, 0, (0.0, 0.0), plot_area);
171    // legend.direction overrides the auto layout from the position.
172    if let Some(dir) = theme.legend_direction {
173        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
174    }
175
176    let mut offset_y = legend_y;
177    let mut offset_x = legend_x;
178
179    for aes in &legend_scales {
180        let scale = scales.get(aes).unwrap();
181
182        if scale.is_discrete() {
183            if is_horizontal {
184                let width = draw_discrete_legend_at(
185                    scales,
186                    &[((*aes).clone(), scale)],
187                    theme,
188                    offset_x,
189                    offset_y,
190                    backend,
191                    guide,
192                    true,
193                )?;
194                offset_x += width + theme.legend_spacing * 2.0;
195            } else {
196                let height = draw_discrete_legend_at(
197                    scales,
198                    &[((*aes).clone(), scale)],
199                    theme,
200                    offset_x,
201                    offset_y,
202                    backend,
203                    guide,
204                    false,
205                )?;
206                offset_y += height + theme.legend_spacing * 2.0;
207            }
208        } else {
209            // Continuous legend (colorbar) — only for color/fill
210            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
211                let height =
212                    draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
213                if is_horizontal {
214                    offset_x += theme.legend_key_width
215                        + theme.legend_text.size * 6.0
216                        + theme.legend_spacing * 2.0;
217                } else {
218                    offset_y += height + theme.legend_spacing * 2.0;
219                }
220            } else {
221                // Continuous size/alpha — draw as discrete-like with sampled breaks
222                let height = draw_discrete_legend_at(
223                    scales,
224                    &[((*aes).clone(), scale)],
225                    theme,
226                    offset_x,
227                    offset_y,
228                    backend,
229                    guide,
230                    is_horizontal,
231                )?;
232                if is_horizontal {
233                    offset_x += theme.legend_key_width
234                        + theme.legend_text.size * 6.0
235                        + theme.legend_spacing * 2.0;
236                } else {
237                    offset_y += height + theme.legend_spacing * 2.0;
238                }
239            }
240        }
241    }
242
243    Ok(())
244}
245
246/// How many rows beyond the first a horizontal legend wraps onto — `max`
247/// across guides sharing one row (several discrete guides side by side),
248/// `sum` for guides stacked vertically instead. Exactly `layout_built`'s
249/// own `extras.legend_extra_rows` computation, so anything derived from
250/// it here can't disagree with the `plot_area.y` that count already
251/// produced.
252fn legend_rows_beyond_first(
253    is_horizontal: bool,
254    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
255) -> usize {
256    if is_horizontal {
257        legend_plans
258            .values()
259            .map(|p| p.rows.saturating_sub(1))
260            .max()
261            .unwrap_or(0)
262    } else {
263        legend_plans
264            .values()
265            .map(|p| p.rows.saturating_sub(1))
266            .sum()
267    }
268}
269
270/// A horizontal (`Top`/`Bottom`) legend's own band height —
271/// `legend_margin.top` plus `(1 + legend_extra_rows)` row-heights
272/// (`legend_key_height + legend_spacing` each) plus `legend_margin.bottom`
273/// — exactly what `PlotLayout::compute_with_extras` reserves for it
274/// (#233).
275pub(crate) fn legend_band_height(theme: &Theme, legend_extra_rows: usize) -> f64 {
276    theme.legend_margin.top
277        + (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
278        + theme.legend_margin.bottom
279}
280
281/// #233: how much `renderer.rs`'s title/subtitle drawing must additionally
282/// lift itself above `plot_area.y` so neither overlaps a `Top` legend —
283/// the same idea as its existing `x_axis_lift` (a top x-axis also
284/// reserves space above the panel). Zero when there's no legend to draw,
285/// or its position isn't `Top`: byte-identical to before in every other
286/// case.
287pub(crate) fn top_legend_lift(
288    theme: &Theme,
289    scales: &ScaleSet,
290    suppressed: &std::collections::HashSet<Aesthetic>,
291    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
292) -> f64 {
293    if !matches!(theme.legend_position, LegendPosition::Top) {
294        return 0.0;
295    }
296    if legend_scales_for(scales, suppressed).is_empty() {
297        return 0.0;
298    }
299    let is_horizontal = match theme.legend_direction {
300        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
301        None => true, // Top is always the horizontal auto-position.
302    };
303    legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans))
304}
305
306/// Estimate the on-canvas footprint `(width, height)` of every legend
307/// scale about to be drawn, stacked the same way `draw_legend_planned`
308/// stacks them (each one's own height summed, the widest width shared)
309/// — used only by `legend_position`'s `Inside` arm (#225) to place the
310/// *whole* legend block by justification instead of always anchoring
311/// its top-left corner there. Mirrors each draw function's own returned
312/// height/width formula exactly (`draw_continuous_legend_at`,
313/// `draw_discrete_legend_at`, `draw_discrete_legend_grid`) without
314/// drawing anything. A `legend_plans` entry (an explicit `nrow`/`ncol`
315/// grid — auto-wrap never applies to a non-horizontal legend like
316/// `Inside`) is honoured for an accurate multi-row/-column estimate;
317/// everything else uses the single-column formula.
318fn estimate_legend_box(
319    scales: &ScaleSet,
320    legend_scales: &[Aesthetic],
321    theme: &Theme,
322    guide: &GuideLegend,
323    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
324) -> (f64, f64) {
325    let mut total_h = 0.0_f64;
326    let mut max_w = 0.0_f64;
327    for aes in legend_scales {
328        let Some(scale) = scales.get(aes) else {
329            continue;
330        };
331        let breaks = scale.breaks();
332        if breaks.is_empty() {
333            continue;
334        }
335        let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
336        let title_offset = if !title.is_empty() {
337            theme.legend_title.size + 4.0
338        } else {
339            0.0
340        };
341        // #236: a size legend's key box (and the row height it sits in)
342        // grows to fit its largest key — see `size_key_diameter`.
343        let diameter = size_key_diameter(aes, scales, scale, &breaks);
344        let key_width = theme.legend_key_width.max(diameter);
345        let key_height = theme.legend_key_height.max(key_width);
346        let single_col_width = key_width + theme.legend_spacing + theme.legend_text.size * 6.0;
347        let is_continuous_bar =
348            matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
349        let (w, h) = if is_continuous_bar {
350            (single_col_width, title_offset + key_height * 8.0)
351        } else if let Some(plan) = legend_plans.get(aes) {
352            let w = if plan.col_pitch > 0.0 {
353                plan.cols as f64 * plan.col_pitch
354            } else {
355                single_col_width
356            };
357            (w, title_offset + plan.rows as f64 * key_height)
358        } else {
359            (
360                single_col_width,
361                title_offset + breaks.len() as f64 * key_height,
362            )
363        };
364        total_h += h;
365        max_w = max_w.max(w);
366    }
367    (max_w, total_h)
368}
369
370/// Clamp a `[pos, pos + size]` span inside `[lo, hi]` — `pos` alone when
371/// `size` already overflows `[lo, hi]` (a legend taller/wider than the
372/// canvas: nothing to do but left/top-align it and let it overflow, same
373/// as #225's old unconditional top-left anchor did for every `Inside`
374/// legend).
375fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
376    if size >= hi - lo {
377        lo
378    } else {
379        pos.clamp(lo, hi - size)
380    }
381}
382
383/// Compute legend origin based on position setting.
384///
385/// `legend_extra_rows` is #233's `LayoutExtras::legend_extra_rows` — a
386/// horizontal legend wrapped onto more than one row by `plan_legend`'s
387/// auto-wrap/explicit grid. Only `Top` reads it: `PlotLayout::
388/// compute_with_extras` reserves a `legend_band_height` row directly
389/// above `plot_area` (ending exactly at `plot_area.y`), so backing off
390/// `plot_area.y` by that same band content (everything but
391/// `legend_margin.top`, which is the gap *above* where drawing starts)
392/// lands the legend's first row exactly inside its reserved band — after
393/// the title/subtitle and the plot's own outer margin, rather than
394/// #233's bug (an absolute-from-canvas-origin `legend_margin.top`,
395/// overlapping both).
396///
397/// `legend_size_hint` (#225) is the legend's own estimated `(width,
398/// height)` — see `estimate_legend_box` — read only by `Inside`. ggplot2
399/// defaults `legend.justification.inside` to `legend.position.inside`:
400/// the legend's own fractional point `(fx, fy)` (measured from its own
401/// bottom-left, in the same `0..=1` convention `Inside`'s `(fx, fy)`
402/// already uses) lands exactly on the panel's point `(fx, fy)`, rather
403/// than always anchoring the legend's top-left corner there (#225's
404/// bug: a legend taller than roughly `(1 - fy) * plot_area.height` —
405/// trivially true for a tall continuous colourbar near `fy = 0`, "near
406/// the bottom" — ran off the canvas, and a discrete legend with enough
407/// levels did exactly the same). `canvas` is a last-resort clamp for a
408/// legend taller or wider than the *panel*: still on the canvas, even if
409/// that means spilling outside the panel itself.
410///
411/// Every other position ignores `legend_size_hint`; callers that don't
412/// have a real estimate in hand (the deprecated `draw_legend`, or where
413/// the returned `y` is unused) pass `(0.0, 0.0)` and `plot_area` itself
414/// as `canvas` — harmless, since neither matters there.
415///
416/// Returns (x, y, is_horizontal).
417pub(crate) fn legend_position(
418    theme: &Theme,
419    plot_area: &Rect,
420    legend_extra_rows: usize,
421    legend_size_hint: (f64, f64),
422    canvas: &Rect,
423) -> (f64, f64, bool) {
424    match theme.legend_position {
425        LegendPosition::Right => (
426            plot_area.x + plot_area.width + theme.legend_margin.left,
427            plot_area.y + theme.legend_margin.top,
428            false,
429        ),
430        LegendPosition::Left => (
431            theme.legend_margin.left,
432            plot_area.y + theme.legend_margin.top,
433            false,
434        ),
435        LegendPosition::Top => (
436            plot_area.x + theme.legend_margin.left,
437            plot_area.y - legend_band_height(theme, legend_extra_rows) + theme.legend_margin.top,
438            true,
439        ),
440        LegendPosition::Bottom => (
441            plot_area.x + theme.legend_margin.left,
442            plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
443            true,
444        ),
445        LegendPosition::None => (0.0, 0.0, false),
446        LegendPosition::Inside(fx, fy) => {
447            let (legend_w, legend_h) = legend_size_hint;
448            let raw_x = plot_area.x + fx * (plot_area.width - legend_w);
449            // fy = 1 is the panel's top in ggplot-rs's convention (y grows
450            // down in SVG space, same flip the old formula already had).
451            let raw_y = plot_area.y + (1.0 - fy) * (plot_area.height - legend_h);
452            let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
453            let y = clamp_span(raw_y, legend_h, canvas.y, canvas.y + canvas.height);
454            (x, y, false)
455        }
456    }
457}
458
459/// For an `Aesthetic::Size` scale, the largest mapped key's diameter
460/// across `breaks` (#236: ggplot2 draws each size key at its break's own
461/// mapped size and grows the key box to fit the largest one, rather than
462/// clamping every key to the fixed default swatch size — which made
463/// every key past a certain size draw identically, the reported bug).
464/// `0.0` for every other aesthetic, or if every mapped size already fits
465/// the theme's default `legend_key_width`, so a caller can just `.max()`
466/// it into the usual fixed size with no special-casing and no change to
467/// existing output when nothing needs the extra room.
468pub(crate) fn size_key_diameter(
469    aes: &Aesthetic,
470    scales: &ScaleSet,
471    scale: &dyn crate::scale::Scale,
472    breaks: &[(f64, String)],
473) -> f64 {
474    if *aes != Aesthetic::Size {
475        return 0.0;
476    }
477    let max_r = breaks
478        .iter()
479        .map(|(pos, label)| {
480            scale
481                .map_fraction_to_size(*pos)
482                .or_else(|| scales.map_size(&Value::Str(label.clone())))
483                .unwrap_or(3.0)
484        })
485        .fold(0.0_f64, f64::max);
486    max_r * 2.0
487}
488
489/// Draw a discrete legend at a given position. Returns the height used.
490#[allow(clippy::too_many_arguments)]
491/// Draw one discrete legend — or, when `group` has more than one entry
492/// (#239: guide merging), one *combined* legend whose every key overlays
493/// each aesthetic's own glyph at the same position (e.g. a filled swatch
494/// with a mapped-colour outline), the way ggplot2 merges `fill` and
495/// `colour` guides mapped to the same measure through the same scale
496/// spec into one legend instead of two. `group[0]` is the representative
497/// scale for the title, breaks and labels — `group_mergeable_legends`
498/// only ever groups aesthetics whose `breaks()` (and so their shared
499/// title) are already identical, so any member would do.
500fn draw_discrete_legend_at(
501    scales: &ScaleSet,
502    group: &[(Aesthetic, &dyn crate::scale::Scale)],
503    theme: &Theme,
504    legend_x: f64,
505    legend_y: f64,
506    backend: &mut dyn DrawBackend,
507    guide: &GuideLegend,
508    is_horizontal: bool,
509) -> Result<f64, RenderError> {
510    let (_, scale) = &group[0];
511    let mut breaks = scale.breaks();
512    if breaks.is_empty() {
513        return Ok(0.0);
514    }
515
516    // Apply guide reverse
517    if guide.reverse {
518        breaks.reverse();
519    }
520
521    // #236: a size legend's key box (and the row height it sits in) grows
522    // to fit its largest key — see `size_key_diameter`. A no-op (both
523    // `.max()`s keep the theme defaults) for every other aesthetic, and
524    // for a size legend whose largest key already fits them. At most one
525    // `group` member is ever `Aesthetic::Size` (`LEGEND_AESTHETICS` has
526    // only one), so taking the max across the group is exactly the same
527    // as the single-aesthetic case when `group.len() == 1`.
528    let diameter = group
529        .iter()
530        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
531        .fold(0.0_f64, f64::max);
532    let swatch_size = theme.legend_key_width.max(diameter);
533    let item_height = theme.legend_key_height.max(swatch_size);
534
535    // Draw legend title (guide title overrides scale name)
536    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
537    let legend_family = if theme.legend_title.family.is_empty() {
538        None
539    } else {
540        Some(theme.legend_title.family.clone())
541    };
542    let title_offset = if !title.is_empty() {
543        backend.draw_text(
544            title,
545            (legend_x, legend_y),
546            &TextStyle {
547                color: theme.legend_title.color,
548                size: theme.legend_title.size,
549                anchor: TextAnchor::Start,
550                angle: 0.0,
551                family: legend_family,
552                face: theme.legend_title.face,
553                bg_colour: None,
554                bg_r: 0.0,
555            },
556        )?;
557        theme.legend_title.size + 4.0
558    } else {
559        0.0
560    };
561
562    let items_y = legend_y + title_offset;
563
564    // Draw legend background
565    if theme.legend_background.visible {
566        let total_height = breaks.len() as f64 * item_height;
567        let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
568        if let Some(fill) = theme.legend_background.fill {
569            backend.draw_rect(
570                (legend_x - 2.0, items_y - 2.0),
571                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
572                &RectStyle {
573                    fill: Some(fill),
574                    stroke: theme.legend_background.color,
575                    stroke_width: theme.legend_background.width,
576                    alpha: 1.0,
577                    clip: false,
578                    corner_radius: 0.0,
579                },
580            )?;
581        }
582    }
583
584    // Horizontal legends lay keys left-to-right; vertical ones stack downward.
585    let mut cur_x = legend_x;
586    for (i, (pos, label)) in breaks.iter().enumerate() {
587        let x = if is_horizontal { cur_x } else { legend_x };
588        let y = if is_horizontal {
589            items_y
590        } else {
591            items_y + i as f64 * item_height
592        };
593        let center_x = x + swatch_size / 2.0;
594        let center_y = y + swatch_size / 2.0;
595
596        // Draw legend key background
597        if theme.legend_key.visible {
598            if let Some(fill) = theme.legend_key.fill {
599                backend.draw_rect(
600                    (x, y),
601                    (x + swatch_size, y + swatch_size),
602                    &RectStyle {
603                        fill: Some(fill),
604                        stroke: theme.legend_key.color,
605                        stroke_width: theme.legend_key.width,
606                        alpha: 1.0,
607                        clip: false,
608                        corner_radius: 0.0,
609                    },
610                )?;
611            }
612        }
613
614        // Draw every group member's own glyph, overlaid at the same
615        // position (#239) — a single swatch for the common, unmerged
616        // case (`group.len() == 1`).
617        for (member_aes, member_scale) in group {
618            draw_legend_swatch(
619                backend,
620                scales,
621                member_aes,
622                *member_scale,
623                theme,
624                label,
625                *pos,
626                x,
627                y,
628                center_x,
629                center_y,
630                swatch_size,
631            )?;
632        }
633
634        // Label
635        let label_family = if theme.legend_text.family.is_empty() {
636            None
637        } else {
638            Some(theme.legend_text.family.clone())
639        };
640        backend.draw_text(
641            label,
642            (x + swatch_size + theme.legend_spacing, center_y),
643            &TextStyle {
644                color: theme.legend_text.color,
645                size: theme.legend_text.size,
646                anchor: TextAnchor::Start,
647                angle: 0.0,
648                family: label_family,
649                face: theme.legend_text.face,
650                bg_colour: None,
651                bg_r: 0.0,
652            },
653        )?;
654        if is_horizontal {
655            // advance past swatch + spacing + estimated label width + gap
656            cur_x = x
657                + swatch_size
658                + theme.legend_spacing
659                + (label.chars().count() as f64) * theme.legend_text.size * 0.62
660                + theme.legend_spacing * 3.0;
661        }
662    }
663
664    if is_horizontal {
665        Ok(cur_x - legend_x) // width used
666    } else {
667        Ok(title_offset + breaks.len() as f64 * item_height)
668    }
669}
670
671/// Draw a continuous colorbar legend at a given position. Returns the height used.
672fn draw_continuous_legend_at(
673    scale: &dyn crate::scale::Scale,
674    theme: &Theme,
675    legend_x: f64,
676    legend_y: f64,
677    backend: &mut dyn DrawBackend,
678    guide: &GuideLegend,
679) -> Result<f64, RenderError> {
680    let breaks = scale.breaks();
681    if breaks.is_empty() {
682        return Ok(0.0);
683    }
684
685    let bar_width = theme.legend_key_width;
686    let bar_height = theme.legend_key_height * 8.0;
687
688    // Draw legend title (guide title overrides scale name)
689    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
690    let cont_family = if theme.legend_title.family.is_empty() {
691        None
692    } else {
693        Some(theme.legend_title.family.clone())
694    };
695    let title_offset = if !title.is_empty() {
696        backend.draw_text(
697            title,
698            (legend_x, legend_y),
699            &TextStyle {
700                color: theme.legend_title.color,
701                size: theme.legend_title.size,
702                anchor: TextAnchor::Start,
703                angle: 0.0,
704                family: cont_family,
705                face: theme.legend_title.face,
706                bg_colour: None,
707                bg_r: 0.0,
708            },
709        )?;
710        theme.legend_title.size + 4.0
711    } else {
712        0.0
713    };
714
715    let bar_top = legend_y + title_offset;
716
717    // Draw legend background
718    if theme.legend_background.visible {
719        let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
720        if let Some(fill) = theme.legend_background.fill {
721            backend.draw_rect(
722                (legend_x - 2.0, bar_top - 2.0),
723                (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
724                &RectStyle {
725                    fill: Some(fill),
726                    stroke: theme.legend_background.color,
727                    stroke_width: theme.legend_background.width,
728                    alpha: 1.0,
729                    clip: false,
730                    corner_radius: 0.0,
731                },
732            )?;
733        }
734    }
735
736    // Draw gradient bar as N thin horizontal slices
737    // Use data-domain values to avoid double-normalization in map_to_color()
738    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
739    let n_slices = 50;
740    let slice_height = bar_height / n_slices as f64;
741    for i in 0..n_slices {
742        let t = 1.0 - i as f64 / n_slices as f64;
743        let data_val = data_min + t * (data_max - data_min);
744        let color = scale
745            .map_to_color(&Value::Float(data_val))
746            .unwrap_or((127, 127, 127));
747        let sy = bar_top + i as f64 * slice_height;
748        backend.draw_rect(
749            (legend_x, sy),
750            (legend_x + bar_width, sy + slice_height + 0.5),
751            &RectStyle {
752                fill: Some(color),
753                stroke: None,
754                stroke_width: 0.0,
755                alpha: 1.0,
756                clip: false,
757                corner_radius: 0.0,
758            },
759        )?;
760    }
761
762    // Draw border
763    // (Ticks reuse its colour and width even when the frame is hidden.)
764    let border = theme.legend_bar_border_line();
765    let border_style = LineStyle {
766        color: border.color,
767        width: border.width,
768        alpha: 1.0,
769        linetype: border.linetype,
770        ..Default::default()
771    };
772    if border.visible {
773        backend.draw_line(
774            &[
775                (legend_x, bar_top),
776                (legend_x + bar_width, bar_top),
777                (legend_x + bar_width, bar_top + bar_height),
778                (legend_x, bar_top + bar_height),
779                (legend_x, bar_top),
780            ],
781            &border_style,
782        )?;
783    }
784
785    // Draw tick marks and labels
786    let tick_len = 3.0;
787    for (pos, label) in &breaks {
788        let tick_y = bar_top + bar_height * (1.0 - pos);
789        backend.draw_line(
790            &[
791                (legend_x + bar_width, tick_y),
792                (legend_x + bar_width + tick_len, tick_y),
793            ],
794            &border_style,
795        )?;
796        let tick_family = if theme.legend_text.family.is_empty() {
797            None
798        } else {
799            Some(theme.legend_text.family.clone())
800        };
801        backend.draw_text(
802            label,
803            (
804                legend_x + bar_width + tick_len + theme.legend_spacing,
805                tick_y,
806            ),
807            &TextStyle {
808                color: theme.legend_text.color,
809                size: theme.legend_text.size,
810                anchor: TextAnchor::Start,
811                angle: 0.0,
812                family: tick_family,
813                face: theme.legend_text.face,
814                bg_colour: None,
815                bg_r: 0.0,
816            },
817        )?;
818    }
819
820    Ok(title_offset + bar_height)
821}
822
823/// Draw one legend key's swatch (not its label) for `aes`, at `(x, y)`
824/// (top-left) / `(center_x, center_y)` (centre), `swatch_size` square.
825/// Shared by the unchanged [`draw_discrete_legend_at`] and the B3-2
826/// planner-driven [`draw_legend_planned`], so both draw pixel-identical
827/// swatches.
828///
829/// A key with no aesthetic-mapped colour of its own (`Shape`, `Linetype`,
830/// `Size`, `Alpha`, `Linewidth` — `Color`/`Fill` always have one, from
831/// `scales.map_color`) falls back to `theme.geom.ink`, then
832/// `theme.legend_text.color` (#237: a *fixed* dark grey here was almost
833/// invisible on a dark panel in a theme that sets `legend_text.color`
834/// for readability but never thought to also set the less commonly used
835/// `geom.ink`) — the same two-tier fallback `GeomPoint`/`GeomText`'s own
836/// `apply_element_geom` effectively uses for an unmapped panel mark
837/// (`ink` if the theme sets it, otherwise its own default), extended one
838/// more tier since a key glyph has no mapped colour of its own to start
839/// from the way a panel mark's default colour does. **Visible default
840/// change:** the base theme's `legend_text.color` is pure black
841/// (`(0, 0, 0)`), not the old fallback's dark grey (`(50, 50, 50)`) — a
842/// small, intentional difference (documented in the CHANGELOG), not a
843/// behaviour-preserving refactor.
844#[allow(clippy::too_many_arguments)]
845fn draw_legend_swatch(
846    backend: &mut dyn DrawBackend,
847    scales: &ScaleSet,
848    aes: &Aesthetic,
849    scale: &dyn crate::scale::Scale,
850    theme: &Theme,
851    label: &str,
852    pos: f64,
853    x: f64,
854    y: f64,
855    center_x: f64,
856    center_y: f64,
857    swatch_size: f64,
858) -> Result<(), RenderError> {
859    let value = Value::Str(label.to_string());
860    match aes {
861        Aesthetic::Color | Aesthetic::Fill => {
862            let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
863            let style = RectStyle {
864                fill: Some(color),
865                stroke: None,
866                stroke_width: 0.0,
867                alpha: 1.0,
868                clip: false,
869                corner_radius: 0.0,
870            };
871            // #206: the "no data" key draws its scale's na_pattern
872            // instead of a flat swatch, when set.
873            if scale.is_na_break(label) {
874                if let Some(p) = scale.na_fill_pattern() {
875                    return backend.draw_rect_pattern(
876                        (x, y),
877                        (x + swatch_size, y + swatch_size),
878                        &p,
879                        &style,
880                    );
881                }
882            }
883            backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
884        }
885        Aesthetic::Shape => {
886            let shape = scales
887                .map_shape(&value)
888                .unwrap_or(crate::render::backend::PointShape::Circle);
889            backend.draw_shape(
890                (center_x, center_y),
891                swatch_size / 3.0,
892                &PointStyle {
893                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
894                    alpha: 1.0,
895                    filled: true,
896                    shape,
897                },
898            )?;
899        }
900        Aesthetic::Linetype => {
901            let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
902            backend.draw_line(
903                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
904                &LineStyle {
905                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
906                    width: 1.5,
907                    alpha: 1.0,
908                    linetype: lt,
909                    ..Default::default()
910                },
911            )?;
912        }
913        Aesthetic::Size => {
914            let size = scale
915                .map_fraction_to_size(pos)
916                .or_else(|| scales.map_size(&value))
917                .unwrap_or(3.0);
918            backend.draw_shape(
919                (center_x, center_y),
920                size.min(swatch_size / 2.0),
921                &PointStyle {
922                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
923                    alpha: 1.0,
924                    filled: true,
925                    shape: crate::render::backend::PointShape::Circle,
926                },
927            )?;
928        }
929        Aesthetic::Alpha => {
930            let alpha = scale
931                .map_fraction_to_alpha(pos)
932                .or_else(|| scales.map_alpha(&value))
933                .unwrap_or(1.0);
934            backend.draw_rect(
935                (x, y),
936                (x + swatch_size, y + swatch_size),
937                &RectStyle {
938                    fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
939                    stroke: None,
940                    stroke_width: 0.0,
941                    alpha,
942                    clip: false,
943                    corner_radius: 0.0,
944                },
945            )?;
946        }
947        Aesthetic::Linewidth => {
948            let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
949            backend.draw_line(
950                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
951                &LineStyle {
952                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
953                    width: width.min(swatch_size),
954                    alpha: 1.0,
955                    linetype: Linetype::Solid,
956                    ..Default::default()
957                },
958            )?;
959        }
960        _ => {}
961    }
962    Ok(())
963}
964
965// ─── B3-2: nrow/ncol/byrow, auto-wrap, "+N more" ───────────────────────────
966
967/// One legend key's grid placement, in the planner's own pixel space
968/// (relative to the legend's own origin — `row` is 0-indexed top row,
969/// `x_offset` is the horizontal pitch from the first column).
970#[derive(Clone, Debug)]
971pub(crate) struct LegendEntry {
972    pub break_idx: usize,
973    pub row: usize,
974    pub x_offset: f64,
975    /// A truncated-with-ellipsis display label (#159), set only when a
976    /// vertical multi-column grid still doesn't fit the canvas after
977    /// reducing its column count as far as it can go: the full label is
978    /// still available (e.g. for a `<title>` tooltip) via the scale's own
979    /// `breaks()[break_idx]`. `None` draws the break's label unchanged —
980    /// the common, byte-identical case.
981    pub label_override: Option<String>,
982}
983
984/// A trailing "+{k} more" marker when the legend doesn't have room for
985/// every key (D7). Not a mark (`draw_text` only — no swatch, no
986/// `data-geom`/class).
987#[derive(Clone, Debug)]
988pub(crate) struct LegendOverflow {
989    pub dropped: usize,
990    pub row: usize,
991    pub x_offset: f64,
992    pub text: String,
993}
994
995/// A non-default legend layout (B3-2): an explicit `nrow`/`ncol` grid, or
996/// an auto-wrapped horizontal legend. `None` from [`plan_legend`] means
997/// "nothing changes" — the caller keeps the 0.21 single-row/single-column
998/// path verbatim, so a plot that doesn't need this is byte-identical.
999#[derive(Clone, Debug)]
1000pub(crate) struct LegendPlan {
1001    pub rows: usize,
1002    pub entries: Vec<LegendEntry>,
1003    pub overflow: Option<LegendOverflow>,
1004    /// Columns in an explicit grid (always 1 for the horizontal auto-wrap
1005    /// path, which never adds columns). Review #158 finding 3: a vertical
1006    /// legend with `ncol > 1` needs `(cols − 1) · col_pitch` of extra
1007    /// width reserved, the same way extra rows are reserved for height.
1008    pub cols: usize,
1009    /// The per-column horizontal pitch an explicit grid uses (0 for the
1010    /// auto-wrap path, which doesn't have columns in this sense).
1011    pub col_pitch: f64,
1012}
1013
1014/// Per-key horizontal pitch (swatch + spacing + label + gap): the larger of
1015/// today's crude per-character advance (`draw_discrete_legend_at`'s
1016/// `cur_x` formula) and a `text_width`-based measurement, so a label that
1017/// ties under both estimates stays on the byte-identical single-row path.
1018fn legend_pitch(label: &str, theme: &Theme) -> f64 {
1019    let swatch = theme.legend_key_width;
1020    let spacing = theme.legend_spacing;
1021    let text_size = theme.legend_text.size;
1022    let legacy =
1023        swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
1024    let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
1025    legacy.max(measured)
1026}
1027
1028/// Plan a non-default legend layout (B3-2, D7). `avail_width` is the
1029/// horizontal room for a horizontal legend's flow-pack / overflow check
1030/// (`plot_area.x + plot_area.width - legend_x`); `avail_height` only feeds
1031/// the default `max_rows` formula. Returns `None` when nothing changes:
1032/// no explicit `nrow`/`ncol`, and (for a horizontal legend) the one-line
1033/// layout already fits — or the legend is vertical, where auto-wrap never
1034/// applies (D7 is horizontal-only; `nrow`/`ncol` still work on either).
1035pub(crate) fn plan_legend(
1036    breaks: &[(f64, String)],
1037    guide: &GuideLegend,
1038    theme: &Theme,
1039    avail_width: f64,
1040    avail_height: f64,
1041    is_horizontal: bool,
1042) -> Option<LegendPlan> {
1043    let n = breaks.len();
1044    if n == 0 {
1045        return None;
1046    }
1047
1048    // Explicit grid: ggplot2's `nrow`/`ncol`, column-major unless `byrow`.
1049    if guide.nrow.is_some() || guide.ncol.is_some() {
1050        let (rows, cols) = match (guide.nrow, guide.ncol) {
1051            (Some(nrow), Some(ncol)) => (nrow, ncol),
1052            (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
1053            (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
1054            (None, None) => unreachable!("checked above"),
1055        };
1056        let rows = rows.max(1);
1057        let cols = cols.max(1);
1058        let col_pitch = breaks
1059            .iter()
1060            .map(|(_, l)| legend_pitch(l, theme))
1061            .fold(0.0_f64, f64::max);
1062        let entries = (0..n)
1063            .map(|i| {
1064                let (row, col) = if guide.byrow {
1065                    (i / cols, i % cols)
1066                } else {
1067                    (i % rows, i / rows)
1068                };
1069                LegendEntry {
1070                    break_idx: i,
1071                    row,
1072                    x_offset: col as f64 * col_pitch,
1073                    label_override: None,
1074                }
1075            })
1076            .collect();
1077        return Some(LegendPlan {
1078            rows,
1079            entries,
1080            overflow: None,
1081            cols,
1082            col_pitch,
1083        });
1084    }
1085
1086    // Auto mode: D7 scopes auto-wrap to horizontal legends only.
1087    if !is_horizontal {
1088        return None;
1089    }
1090
1091    let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1092    let total_width: f64 = pitches.iter().sum();
1093    if total_width <= avail_width.max(0.0) {
1094        return None; // fits on one line — byte-identical to 0.21.
1095    }
1096
1097    let item_height = theme.legend_key_height.max(1.0);
1098    let row_limit = guide
1099        .max_rows
1100        .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1101        .max(1);
1102
1103    let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1104    let mut row = 0usize;
1105    let mut cur_x = 0.0_f64;
1106    for (i, &p) in pitches.iter().enumerate() {
1107        if cur_x > 0.0 && cur_x + p > avail_width {
1108            if row + 1 >= row_limit {
1109                break; // out of rows — the rest overflow into "+N more".
1110            }
1111            row += 1;
1112            cur_x = 0.0;
1113        }
1114        entries.push(LegendEntry {
1115            break_idx: i,
1116            row,
1117            x_offset: cur_x,
1118            label_override: None,
1119        });
1120        cur_x += p;
1121    }
1122
1123    if entries.len() >= n {
1124        return Some(LegendPlan {
1125            rows: row + 1,
1126            entries,
1127            overflow: None,
1128            cols: 1,
1129            col_pitch: 0.0,
1130        });
1131    }
1132
1133    // Overflow: make room for "+{k} more" on the last row, dropping
1134    // entries from its tail until it fits (or the row is empty).
1135    let mut dropped = n - entries.len();
1136    loop {
1137        let text = format!("+{dropped} more");
1138        let more_pitch = legend_pitch(&text, theme);
1139        let row_count = entries.iter().filter(|e| e.row == row).count();
1140        let last_x = entries
1141            .iter()
1142            .filter(|e| e.row == row)
1143            .map(|e| e.x_offset + pitches[e.break_idx])
1144            .fold(0.0_f64, f64::max);
1145        if last_x + more_pitch <= avail_width || row_count == 0 {
1146            return Some(LegendPlan {
1147                rows: row + 1,
1148                entries,
1149                overflow: Some(LegendOverflow {
1150                    dropped,
1151                    row,
1152                    x_offset: last_x,
1153                    text,
1154                }),
1155                cols: 1,
1156                col_pitch: 0.0,
1157            });
1158        }
1159        entries.pop();
1160        dropped += 1;
1161    }
1162}
1163
1164/// How many of a plot's legend scales are discrete (the ones B3-2's
1165/// wrap/grid applies to) — used to split the available horizontal room
1166/// among them when several sit side by side (sipemu/ggplot-rs#155).
1167fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1168    legend_scales
1169        .iter()
1170        .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
1171        .count()
1172        .max(1)
1173}
1174
1175/// How many of a plot's legend scales are continuous (a colourbar, or a
1176/// sampled continuous size/alpha legend) — each reserves its own fixed
1177/// width share of a horizontal row (#170 / review #167 finding 7: a
1178/// bottom legend mixing a colourbar with a discrete guide ignored the
1179/// colourbar's share, so the discrete guide's auto-wrap/grid was planned
1180/// against the *whole* row and could still run off the right edge).
1181fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1182    legend_scales
1183        .iter()
1184        .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
1185        .count()
1186}
1187
1188/// The fixed horizontal width one continuous guide reserves in a
1189/// horizontal row — the same per-guide step `draw_legend_planned` already
1190/// advances `offset_x` by once it has drawn one (see its two
1191/// `is_horizontal` arms for a continuous scale).
1192fn continuous_guide_width(theme: &Theme) -> f64 {
1193    theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
1194}
1195
1196/// Plan every discrete legend scale's B3-2 layout (explicit `nrow`/`ncol`
1197/// grid, or auto-wrap) independently (sipemu/ggplot-rs#155): previously
1198/// this only ran for the plot's single discrete guide, leaving a second
1199/// simultaneous discrete guide (e.g. both `colour` and `shape` mapped to
1200/// discrete variables) always on the unwrapped 0.21 path. When several
1201/// discrete guides sit side by side (`is_horizontal`), the available
1202/// width is split evenly among them first, so each one wraps against its
1203/// own share rather than the whole row (which would let it claim more
1204/// room than its neighbours leave). Guides that stack vertically instead
1205/// each get the full `avail_width`, since they don't compete for it.
1206/// Returns one entry per discrete guide that needs anything beyond the
1207/// single-row/single-column path — a guide absent from the map is
1208/// byte-identical to 0.21/0.22.
1209#[allow(clippy::too_many_arguments)]
1210pub(crate) fn plan_all_legends(
1211    scales: &ScaleSet,
1212    guide: &GuideLegend,
1213    theme: &Theme,
1214    suppressed: &std::collections::HashSet<Aesthetic>,
1215    avail_width: f64,
1216    avail_height: f64,
1217    is_horizontal: bool,
1218    warnings: &mut Vec<String>,
1219) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1220    let legend_scales = legend_scales_for(scales, suppressed);
1221    let n_discrete = discrete_guide_count(scales, &legend_scales);
1222    let per_guide_width = if is_horizontal {
1223        // #170: leave the discrete guides only the width left over after
1224        // every continuous guide's own fixed share (a colourbar sitting
1225        // in the same row) — previously `avail_width` was split among
1226        // the discrete guides alone, so a colourbar plus a discrete
1227        // guide could together still overflow the row.
1228        let n_continuous = continuous_guide_count(scales, &legend_scales);
1229        let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
1230        ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
1231    } else {
1232        avail_width
1233    };
1234
1235    let mut plans = std::collections::HashMap::new();
1236    for aes in &legend_scales {
1237        let Some(scale) = scales.get(aes) else {
1238            continue;
1239        };
1240        if !scale.is_discrete() {
1241            continue;
1242        }
1243        let breaks = scale.breaks();
1244        let validated_guide = guide.validated(breaks.len(), warnings);
1245        if let Some(plan) = plan_legend(
1246            &breaks,
1247            &validated_guide,
1248            theme,
1249            per_guide_width,
1250            avail_height,
1251            is_horizontal,
1252        ) {
1253            plans.insert(aes.clone(), plan);
1254        }
1255    }
1256    plans
1257}
1258
1259/// Draw all legends for the plot, honouring each discrete guide's B3-2
1260/// `plan_legend` layout (explicit `nrow`/`ncol`, or an overflowing
1261/// horizontal legend's auto-wrap) when it has one — several simultaneous
1262/// discrete guides each get their own independent plan
1263/// (sipemu/ggplot-rs#155). A guide missing from `legend_plans` falls back
1264/// to the unchanged [`draw_legend`] path — same calls, same order — which
1265/// keeps this byte-identical to 0.21/0.22 for every plot nothing in
1266/// `legend_plans` touches. Used by `renderer.rs` (single and faceted) and
1267/// `compose::capture_legend` (PlotGrid-collected legends).
1268#[allow(clippy::too_many_arguments)]
1269pub(crate) fn draw_legend_planned(
1270    scales: &ScaleSet,
1271    theme: &Theme,
1272    plot_area: &Rect,
1273    // Review #167 finding 1: the width share each discrete guide was
1274    // *planned* against (the same `avail_width` passed to
1275    // `plan_all_legends`), not `plot_area.width`. For a single plot these
1276    // are the same value in practice, but a PlotGrid's collected legend
1277    // (`compose::capture_legend`) measures itself against a fake
1278    // zero-width panel so its origin lands at (0, 0) — stepping by
1279    // `plot_area.width / n` there always advanced by zero, so the second
1280    // discrete guide (e.g. `shape` after `colour`) was drawn directly on
1281    // top of the first instead of beside it.
1282    legend_avail_width: f64,
1283    backend: &mut dyn DrawBackend,
1284    guide: &GuideLegend,
1285    suppressed: &std::collections::HashSet<Aesthetic>,
1286    // Review #158 finding 2: each discrete guide's plan, computed exactly
1287    // once in `layout_built` (`BuiltPlot::legend_plans`) — drawn verbatim
1288    // here rather than recomputed against this (possibly shorter, post-
1289    // row-reservation) `plot_area`, so the drawn "+N more" always matches
1290    // the warning that plan already produced.
1291    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1292) -> Result<(), RenderError> {
1293    if matches!(theme.legend_position, LegendPosition::None) {
1294        return Ok(());
1295    }
1296
1297    let legend_scales = legend_scales_for(scales, suppressed);
1298    if legend_scales.is_empty() {
1299        return Ok(());
1300    }
1301
1302    // #239: group aesthetics whose legend would be visually identical
1303    // (same title, breaks, discrete/continuous-ness) into one combined
1304    // guide, rather than drawing one per aesthetic. Everything below that
1305    // used to iterate `legend_scales` one aesthetic at a time now
1306    // iterates `groups` instead; a group's first member (`representatives`)
1307    // stands in for the whole group wherever only one scale's own
1308    // breaks/title/discreteness is needed (sizing, the B3-2 wrap plan
1309    // lookup, …) — `group_mergeable_legends` only ever groups scales
1310    // whose `breaks()` already match, so any member would do.
1311    let groups = group_mergeable_legends(scales, &legend_scales, guide);
1312    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
1313
1314    // #233: `legend_position`'s `Top` arm needs the same wrapped-row
1315    // count `layout_built` fed `PlotLayout::compute_with_extras` to
1316    // produce this `plot_area` in the first place — recomputed here from
1317    // `legend_plans` (the exact plans that count came from) the same way
1318    // `layout_built` did, so it can never disagree with the band
1319    // `plot_area.y` was actually reserved against. `is_horizontal`'s
1320    // auto value only depends on the position itself (`Top`/`Bottom`),
1321    // overridable by `legend_direction` same as below — computed first
1322    // since `legend_position` needs it to pick `max` (several guides
1323    // sharing a row) vs. `sum` (several guides stacked), the same
1324    // either/or `layout_built` used.
1325    let is_horizontal_hint = match theme.legend_direction {
1326        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
1327        None => matches!(
1328            theme.legend_position,
1329            LegendPosition::Top | LegendPosition::Bottom
1330        ),
1331    };
1332    let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
1333    // #225: `Inside`'s justification needs the legend's own estimated
1334    // footprint and the full canvas to clamp against — cheap to compute
1335    // unconditionally (ignored by every other position). Estimated from
1336    // `representatives`, not every raw aesthetic, so a merged group's
1337    // height is counted once, not once per aesthetic in it.
1338    let legend_size_hint =
1339        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
1340    let canvas = backend.total_area();
1341    let (legend_x, legend_y, mut is_horizontal) = legend_position(
1342        theme,
1343        plot_area,
1344        legend_extra_rows,
1345        legend_size_hint,
1346        &canvas,
1347    );
1348    if let Some(dir) = theme.legend_direction {
1349        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
1350    }
1351    let n_discrete = discrete_guide_count(scales, &representatives);
1352    // #170: match `plan_all_legends`'s share — a continuous guide (e.g. a
1353    // colourbar) sharing this row has already claimed its own fixed
1354    // width, so step the discrete guides by what's left, not the whole
1355    // `legend_avail_width`.
1356    let n_continuous = continuous_guide_count(scales, &representatives);
1357    let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
1358    let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
1359
1360    let mut offset_y = legend_y;
1361    let mut offset_x = legend_x;
1362
1363    for group in &groups {
1364        let representative = &group[0];
1365        let rep_scale = scales.get(representative).unwrap();
1366        let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
1367            .iter()
1368            .map(|a| (a.clone(), scales.get(a).unwrap()))
1369            .collect();
1370
1371        if !rep_scale.is_discrete() {
1372            // Continuous legends (colorbar, or size/alpha/linewidth
1373            // sampled breaks) aren't in scope for B3-2 — unchanged.
1374            if matches!(representative, Aesthetic::Color | Aesthetic::Fill) {
1375                // #239: a merged continuous fill+colour pair draws one
1376                // bar — `group_mergeable_legends` only merges scales
1377                // whose breaks (so, in practice, colours) already match,
1378                // so the other member(s) would draw the identical bar
1379                // again.
1380                let height = draw_continuous_legend_at(
1381                    rep_scale, theme, offset_x, offset_y, backend, guide,
1382                )?;
1383                if is_horizontal {
1384                    offset_x += theme.legend_key_width
1385                        + theme.legend_text.size * 6.0
1386                        + theme.legend_spacing * 2.0;
1387                } else {
1388                    offset_y += height + theme.legend_spacing * 2.0;
1389                }
1390            } else {
1391                let height = draw_discrete_legend_at(
1392                    scales,
1393                    &scale_group,
1394                    theme,
1395                    offset_x,
1396                    offset_y,
1397                    backend,
1398                    guide,
1399                    is_horizontal,
1400                )?;
1401                if is_horizontal {
1402                    offset_x += theme.legend_key_width
1403                        + theme.legend_text.size * 6.0
1404                        + theme.legend_spacing * 2.0;
1405                } else {
1406                    offset_y += height + theme.legend_spacing * 2.0;
1407                }
1408            }
1409            continue;
1410        }
1411
1412        match legend_plans.get(representative) {
1413            None => {
1414                if is_horizontal {
1415                    let width = draw_discrete_legend_at(
1416                        scales,
1417                        &scale_group,
1418                        theme,
1419                        offset_x,
1420                        offset_y,
1421                        backend,
1422                        guide,
1423                        true,
1424                    )?;
1425                    offset_x += width + theme.legend_spacing * 2.0;
1426                } else {
1427                    let height = draw_discrete_legend_at(
1428                        scales,
1429                        &scale_group,
1430                        theme,
1431                        offset_x,
1432                        offset_y,
1433                        backend,
1434                        guide,
1435                        false,
1436                    )?;
1437                    offset_y += height + theme.legend_spacing * 2.0;
1438                }
1439            }
1440            Some(plan) => {
1441                let height = draw_discrete_legend_grid(
1442                    scales,
1443                    &scale_group,
1444                    theme,
1445                    offset_x,
1446                    offset_y,
1447                    backend,
1448                    guide,
1449                    plan,
1450                )?;
1451                if is_horizontal {
1452                    // Each discrete guide gets an equal share of the row
1453                    // (sipemu/ggplot-rs#155) rather than the whole row,
1454                    // which only held for exactly one discrete guide.
1455                    offset_x += per_guide_width;
1456                } else {
1457                    offset_y += height + theme.legend_spacing * 2.0;
1458                }
1459            }
1460        }
1461    }
1462
1463    Ok(())
1464}
1465
1466/// Draw a discrete legend using a [`LegendPlan`] (explicit grid or
1467/// auto-wrap). Returns the total height used (including any "+N more"
1468/// row).
1469#[allow(clippy::too_many_arguments)]
1470/// Wrapped-grid counterpart of [`draw_discrete_legend_at`] — same `group`
1471/// merging (#239).
1472fn draw_discrete_legend_grid(
1473    scales: &ScaleSet,
1474    group: &[(Aesthetic, &dyn crate::scale::Scale)],
1475    theme: &Theme,
1476    legend_x: f64,
1477    legend_y: f64,
1478    backend: &mut dyn DrawBackend,
1479    guide: &GuideLegend,
1480    plan: &LegendPlan,
1481) -> Result<f64, RenderError> {
1482    let (_, scale) = &group[0];
1483    let mut breaks = scale.breaks();
1484    if breaks.is_empty() {
1485        return Ok(0.0);
1486    }
1487    if guide.reverse {
1488        breaks.reverse();
1489    }
1490
1491    // #236: same key-box growth as `draw_discrete_legend_at`.
1492    let diameter = group
1493        .iter()
1494        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
1495        .fold(0.0_f64, f64::max);
1496    let swatch_size = theme.legend_key_width.max(diameter);
1497    let item_height = theme.legend_key_height.max(swatch_size);
1498
1499    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1500    let legend_family = if theme.legend_title.family.is_empty() {
1501        None
1502    } else {
1503        Some(theme.legend_title.family.clone())
1504    };
1505    let title_offset = if !title.is_empty() {
1506        backend.draw_text(
1507            title,
1508            (legend_x, legend_y),
1509            &TextStyle {
1510                color: theme.legend_title.color,
1511                size: theme.legend_title.size,
1512                anchor: TextAnchor::Start,
1513                angle: 0.0,
1514                family: legend_family,
1515                face: theme.legend_title.face,
1516                bg_colour: None,
1517                bg_r: 0.0,
1518            },
1519        )?;
1520        theme.legend_title.size + 4.0
1521    } else {
1522        0.0
1523    };
1524    let items_y = legend_y + title_offset;
1525
1526    for entry in &plan.entries {
1527        let (pos, label) = &breaks[entry.break_idx];
1528        let x = legend_x + entry.x_offset;
1529        let y = items_y + entry.row as f64 * item_height;
1530        let center_x = x + swatch_size / 2.0;
1531        let center_y = y + swatch_size / 2.0;
1532
1533        if theme.legend_key.visible {
1534            if let Some(fill) = theme.legend_key.fill {
1535                backend.draw_rect(
1536                    (x, y),
1537                    (x + swatch_size, y + swatch_size),
1538                    &RectStyle {
1539                        fill: Some(fill),
1540                        stroke: theme.legend_key.color,
1541                        stroke_width: theme.legend_key.width,
1542                        alpha: 1.0,
1543                        clip: false,
1544                        corner_radius: 0.0,
1545                    },
1546                )?;
1547            }
1548        }
1549
1550        for (member_aes, member_scale) in group {
1551            draw_legend_swatch(
1552                backend,
1553                scales,
1554                member_aes,
1555                *member_scale,
1556                theme,
1557                label,
1558                *pos,
1559                x,
1560                y,
1561                center_x,
1562                center_y,
1563                swatch_size,
1564            )?;
1565        }
1566
1567        let label_family = if theme.legend_text.family.is_empty() {
1568            None
1569        } else {
1570            Some(theme.legend_text.family.clone())
1571        };
1572        // #159: a label truncated to fit a vertical multi-column grid
1573        // still shows its full text via a `<title>` tooltip (backends
1574        // without tooltip support just render the truncated text).
1575        let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1576        backend.draw_text_with_title(
1577            display,
1578            (x + swatch_size + theme.legend_spacing, center_y),
1579            &TextStyle {
1580                color: theme.legend_text.color,
1581                size: theme.legend_text.size,
1582                anchor: TextAnchor::Start,
1583                angle: 0.0,
1584                family: label_family,
1585                face: theme.legend_text.face,
1586                bg_colour: None,
1587                bg_r: 0.0,
1588            },
1589            entry.label_override.as_deref().map(|_| label.as_str()),
1590        )?;
1591    }
1592
1593    // The overflow marker is text only — not a mark, no swatch.
1594    if let Some(ov) = &plan.overflow {
1595        let x = legend_x + ov.x_offset;
1596        let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1597        let label_family = if theme.legend_text.family.is_empty() {
1598            None
1599        } else {
1600            Some(theme.legend_text.family.clone())
1601        };
1602        backend.draw_text(
1603            &ov.text,
1604            (x, y),
1605            &TextStyle {
1606                color: theme.legend_text.color,
1607                size: theme.legend_text.size,
1608                anchor: TextAnchor::Start,
1609                angle: 0.0,
1610                family: label_family,
1611                face: theme.legend_text.face,
1612                bg_colour: None,
1613                bg_r: 0.0,
1614            },
1615        )?;
1616    }
1617
1618    Ok(title_offset + plan.rows as f64 * item_height)
1619}
1620
1621#[cfg(test)]
1622mod group_mergeable_legends_tests {
1623    use super::*;
1624    use crate::scale::color::RGBAColor;
1625    use crate::scale::manual::ScaleManual;
1626
1627    #[test]
1628    fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
1629        // #239: in the normal flow, `group_mergeable_legends` is only ever
1630        // handed `legend_scales_for`'s output, which already filters to
1631        // aesthetics present in `scales` — so the `let-else`'s `continue`
1632        // branch (an aesthetic that slipped through with no attached
1633        // scale) is otherwise unreachable from `draw_legend_planned`.
1634        // Exercise it directly: `Fill` is listed but has no scale.
1635        let mut scales = ScaleSet::new();
1636        scales.add(Box::new(ScaleManual::new(
1637            Aesthetic::Color,
1638            vec![("a", RGBAColor::new(200, 0, 0))],
1639        )));
1640        let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
1641        let guide = GuideLegend::default();
1642        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
1643        assert_eq!(groups, vec![vec![Aesthetic::Color]]);
1644    }
1645}