Skip to main content

ggplot_rs/guide/
legend.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3use crate::guide::config::{ColourbarDisplay, GuideColourbar, 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.
12pub(crate) const 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), 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 = draw_continuous_legend_at(
212                    scale,
213                    theme,
214                    offset_x,
215                    offset_y,
216                    backend,
217                    guide,
218                    is_horizontal,
219                )?;
220                if is_horizontal {
221                    // #211: the bar is now drawn along x (wide, not
222                    // narrow) — step by its own length, not the old
223                    // vertical-bar width-plus-side-label estimate.
224                    let (bar_len, _) = continuous_bar_dims(theme, true, &guide.colourbar_for(aes));
225                    offset_x += bar_len + theme.legend_spacing * 2.0;
226                } else {
227                    offset_y += height + theme.legend_spacing * 2.0;
228                }
229            } else {
230                // Continuous size/alpha — draw as discrete-like with sampled breaks
231                let height = draw_discrete_legend_at(
232                    scales,
233                    &[((*aes).clone(), scale)],
234                    theme,
235                    offset_x,
236                    offset_y,
237                    backend,
238                    guide,
239                    is_horizontal,
240                )?;
241                if is_horizontal {
242                    offset_x += theme.legend_key_width
243                        + theme.legend_text.size * 6.0
244                        + theme.legend_spacing * 2.0;
245                } else {
246                    offset_y += height + theme.legend_spacing * 2.0;
247                }
248            }
249        }
250    }
251
252    Ok(())
253}
254
255/// How many rows beyond the first a horizontal legend wraps onto — `max`
256/// across guides sharing one row (several discrete guides side by side),
257/// `sum` for guides stacked vertically instead. Exactly `layout_built`'s
258/// own `extras.legend_extra_rows` computation, so anything derived from
259/// it here can't disagree with the `plot_area.y` that count already
260/// produced.
261fn legend_rows_beyond_first(
262    is_horizontal: bool,
263    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
264) -> usize {
265    if is_horizontal {
266        legend_plans
267            .values()
268            .map(|p| p.rows.saturating_sub(1))
269            .max()
270            .unwrap_or(0)
271    } else {
272        legend_plans
273            .values()
274            .map(|p| p.rows.saturating_sub(1))
275            .sum()
276    }
277}
278
279/// A horizontal (`Top`/`Bottom`) legend's own band height —
280/// `legend_margin.top` plus `(1 + legend_extra_rows)` row-heights
281/// (`legend_key_height + legend_spacing` each) plus `legend_margin.bottom`
282/// — exactly what `PlotLayout::compute_with_extras` reserves for it
283/// (#233).
284pub(crate) fn legend_band_height(theme: &Theme, legend_extra_rows: usize) -> f64 {
285    let m = theme.legend_margin_outer();
286    m.top
287        + (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
288        + m.bottom
289}
290
291/// #233: how much `renderer.rs`'s title/subtitle drawing must additionally
292/// lift itself above `plot_area.y` so neither overlaps a `Top` legend —
293/// the same idea as its existing `x_axis_lift` (a top x-axis also
294/// reserves space above the panel). Zero when there's no legend to draw,
295/// or its position isn't `Top`: byte-identical to before in every other
296/// case.
297///
298/// #577: `legend_band_height` alone only covers the flat discrete-row
299/// case — a continuous, horizontally-drawn Color/Fill colourbar *with its
300/// own title* needs the same extra height `plot.rs` already reserves in
301/// `plot_area.y` via `continuous_horizontal_legend_extra_height` (#211).
302/// Without adding that extra here too, this lift undershot by exactly
303/// that amount, so the plot's own title/subtitle landed on top of the
304/// legend's title instead of above it. `guide` is the same
305/// [`GuideLegend`] `draw_legend_planned` draws with, so "does this guide
306/// have a title" can't disagree between the two.
307pub(crate) fn top_legend_lift(
308    theme: &Theme,
309    scales: &ScaleSet,
310    suppressed: &std::collections::HashSet<Aesthetic>,
311    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
312    guide: &GuideLegend,
313) -> f64 {
314    if !matches!(theme.legend_position, LegendPosition::Top) {
315        // #409: a legend moved to the top by `guide_position` lifts the
316        // titles by its one-row band.
317        return if extra_legend_sides(scales, suppressed, guide, &theme.legend_position)[2] {
318            legend_band_height(theme, 0)
319        } else {
320            0.0
321        };
322    }
323    let legend_scales = legend_scales_for(scales, suppressed);
324    if legend_scales.is_empty() {
325        return 0.0;
326    }
327    let is_horizontal = match theme.legend_direction {
328        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
329        None => true, // Top is always the horizontal auto-position.
330    };
331    let base = legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans));
332    let colorbar_extra = if is_horizontal {
333        legend_scales
334            .iter()
335            .find_map(|aes| {
336                if !matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
337                    return None;
338                }
339                let scale = scales.get(aes)?;
340                if scale.is_discrete() {
341                    return None;
342                }
343                let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
344                let bar_height = continuous_horizontal_bar_height(
345                    theme,
346                    !title.is_empty(),
347                    &guide.colourbar_for(aes),
348                );
349                Some((bar_height - theme.legend_key_height).max(0.0))
350            })
351            .unwrap_or(0.0)
352    } else {
353        0.0
354    };
355    base + colorbar_extra
356}
357
358/// Estimate the on-canvas footprint `(width, height)` of every legend
359/// scale about to be drawn, stacked the same way `draw_legend_planned`
360/// stacks them (each one's own height summed, the widest width shared)
361/// — used only by `legend_position`'s `Inside` arm (#225) to place the
362/// *whole* legend block by justification instead of always anchoring
363/// its top-left corner there. Mirrors each draw function's own returned
364/// height/width formula exactly (`draw_continuous_legend_at`,
365/// `draw_discrete_legend_at`, `draw_discrete_legend_grid`) without
366/// drawing anything. A `legend_plans` entry (an explicit `nrow`/`ncol`
367/// grid — auto-wrap never applies to a non-horizontal legend like
368/// `Inside`) is honoured for an accurate multi-row/-column estimate;
369/// everything else uses the single-column formula.
370fn estimate_legend_box(
371    scales: &ScaleSet,
372    legend_scales: &[Aesthetic],
373    theme: &Theme,
374    guide: &GuideLegend,
375    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
376) -> (f64, f64) {
377    let mut total_h = 0.0_f64;
378    let mut max_w = 0.0_f64;
379    for aes in legend_scales {
380        if let Some((w, h)) = estimate_guide_size(scales, aes, theme, guide, legend_plans) {
381            total_h += h;
382            max_w = max_w.max(w);
383        }
384    }
385    (max_w, total_h)
386}
387
388/// One guide's height when drawn horizontally (a `Top` / `Bottom`
389/// legend) — for `legend_box_just` (#413).
390fn horizontal_guide_height(
391    scales: &ScaleSet,
392    aes: &Aesthetic,
393    theme: &Theme,
394    guide: &GuideLegend,
395    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
396) -> f64 {
397    let Some(scale) = scales.get(aes) else {
398        return 0.0;
399    };
400    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
401    if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
402        return continuous_horizontal_bar_height(
403            theme,
404            !title.is_empty(),
405            &guide.colourbar_for(aes),
406        );
407    }
408    let breaks = scale.breaks();
409    let title_offset = if title.is_empty() {
410        0.0
411    } else {
412        theme.legend_title.size + 4.0
413    };
414    let key = theme.legend_key_height.max(
415        theme
416            .legend_key_width
417            .max(size_key_diameter(aes, scales, scale, &breaks)),
418    );
419    let rows = legend_plans.get(aes).map_or(1, |p| p.rows.max(1)) as f64;
420    let gap = theme.legend_key_gap_y();
421    let bins_labels = if guide.bins_for(aes, scale).is_some() {
422        2.0 + theme.legend_text.size
423    } else {
424        0.0
425    };
426    title_offset + rows * (key + gap) - gap + bins_labels
427}
428
429/// One guide's estimated `(width, height)` as [`estimate_legend_box`]
430/// stacks it (vertical layout); `None` when it draws nothing.
431fn estimate_guide_size(
432    scales: &ScaleSet,
433    aes: &Aesthetic,
434    theme: &Theme,
435    guide: &GuideLegend,
436    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
437) -> Option<(f64, f64)> {
438    {
439        let scale = scales.get(aes)?;
440        let breaks = scale.breaks();
441        if breaks.is_empty() {
442            return None;
443        }
444        let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
445        let title_offset = if !title.is_empty() {
446            theme.legend_title.size + 4.0
447        } else {
448            0.0
449        };
450        // #236: a size legend's key box (and the row height it sits in)
451        // grows to fit its largest key — see `size_key_diameter`.
452        let diameter = size_key_diameter(aes, scales, scale, &breaks);
453        let key_width = theme.legend_key_width.max(diameter);
454        let key_height = theme.legend_key_height.max(key_width);
455        // The widest label and the title as drawn (a fixed six-character
456        // guess let a long label or title run off a right-corner legend).
457        let label_w = breaks
458            .iter()
459            .map(|(_, l)| crate::render::text::text_width(l, theme.legend_text.size))
460            .fold(0.0_f64, f64::max);
461        let title_w = crate::render::text::text_width(title, theme.legend_title.size);
462        let single_col_width = (key_width + theme.legend_spacing + label_w).max(title_w);
463        let is_continuous_bar =
464            matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
465        let (w, h) = if is_continuous_bar {
466            // The bottom break label is centred on the bar's end: its lower
467            // half hangs below the bar (#377). A `guide_colourbar` size
468            // (#409) replaces the default bar's.
469            let bar = guide.colourbar_for(aes);
470            let bar_w = bar.bar_width.unwrap_or(key_width);
471            let bar_h = bar.bar_height.unwrap_or(key_height * 8.0);
472            (
473                (bar_w + theme.legend_spacing + label_w).max(title_w),
474                title_offset + bar_h + theme.legend_text.size / 2.0,
475            )
476        } else if let Some(plan) = legend_plans.get(aes) {
477            let w = if plan.col_pitch > 0.0 {
478                plan.cols as f64 * plan.col_pitch
479            } else {
480                single_col_width
481            };
482            let gap = theme.legend_key_gap_y();
483            (
484                w,
485                title_offset + plan.rows as f64 * (key_height + gap) - gap,
486            )
487        } else {
488            let gap = theme.legend_key_gap_y();
489            (
490                single_col_width,
491                title_offset + breaks.len() as f64 * (key_height + gap) - gap,
492            )
493        };
494        Some((w, h))
495    }
496}
497
498/// Clamp a `[pos, pos + size]` span inside `[lo, hi]` — `pos` alone when
499/// `size` already overflows `[lo, hi]` (a legend taller/wider than the
500/// canvas: nothing to do but left/top-align it and let it overflow, same
501/// as #225's old unconditional top-left anchor did for every `Inside`
502/// legend).
503fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
504    if size >= hi - lo {
505        lo
506    } else {
507        pos.clamp(lo, hi - size)
508    }
509}
510
511/// Compute legend origin based on position setting.
512///
513/// `legend_extra_rows` is #233's `LayoutExtras::legend_extra_rows` — a
514/// horizontal legend wrapped onto more than one row by `plan_legend`'s
515/// auto-wrap/explicit grid. Only `Top` reads it: `PlotLayout::
516/// compute_with_extras` reserves a `legend_band_height` row directly
517/// above `plot_area` (ending exactly at `plot_area.y`), so backing off
518/// `plot_area.y` by that same band content (everything but
519/// `legend_margin.top`, which is the gap *above* where drawing starts)
520/// lands the legend's first row exactly inside its reserved band — after
521/// the title/subtitle and the plot's own outer margin, rather than
522/// #233's bug (an absolute-from-canvas-origin `legend_margin.top`,
523/// overlapping both).
524///
525/// `legend_size_hint` (#225) is the legend's own estimated `(width,
526/// height)` — see `estimate_legend_box` — read only by `Inside`. ggplot2
527/// defaults `legend.justification.inside` to `legend.position.inside`:
528/// the legend's own fractional point `(fx, fy)` (measured from its own
529/// bottom-left, in the same `0..=1` convention `Inside`'s `(fx, fy)`
530/// already uses) lands exactly on the panel's point `(fx, fy)`, rather
531/// than always anchoring the legend's top-left corner there (#225's
532/// bug: a legend taller than roughly `(1 - fy) * plot_area.height` —
533/// trivially true for a tall continuous colourbar near `fy = 0`, "near
534/// the bottom" — ran off the canvas, and a discrete legend with enough
535/// levels did exactly the same). `canvas` is a last-resort clamp for a
536/// legend taller or wider than the *panel*: still on the canvas, even if
537/// that means spilling outside the panel itself.
538///
539/// Every other position ignores `legend_size_hint`; callers that don't
540/// have a real estimate in hand (the deprecated `draw_legend`, or where
541/// the returned `y` is unused) pass `(0.0, 0.0)` and `plot_area` itself
542/// as `canvas` — harmless, since neither matters there.
543///
544/// Returns (x, y, is_horizontal).
545pub(crate) fn legend_position(
546    theme: &Theme,
547    plot_area: &Rect,
548    legend_extra_rows: usize,
549    legend_size_hint: (f64, f64),
550    title_lift: f64,
551    canvas: &Rect,
552) -> (f64, f64, bool) {
553    // #413: `legend.location = "plot"` aligns an outside legend with the
554    // whole plot (inside its margin) instead of the panel.
555    let (ref_x, ref_y) = if theme.legend_location_plot {
556        (
557            canvas.x + theme.plot_margin.left,
558            canvas.y + theme.plot_margin.top,
559        )
560    } else {
561        (plot_area.x, plot_area.y)
562    };
563    // #413: `legend_box_spacing` replaces the panel-facing margin side.
564    let om = theme.legend_margin_outer();
565    match theme.legend_position {
566        LegendPosition::Right => (
567            plot_area.x + plot_area.width + om.left,
568            ref_y + om.top,
569            false,
570        ),
571        LegendPosition::Left => (om.left, ref_y + om.top, false),
572        LegendPosition::Top => (
573            ref_x + om.left,
574            plot_area.y - legend_band_height(theme, legend_extra_rows) + om.top,
575            true,
576        ),
577        LegendPosition::Bottom => (
578            ref_x + om.left,
579            // Below the x axis — at most the 30px it used to assume, so an
580            // axis-less theme (`theme_void`) keeps the legend inside the band
581            // the layout reserved rather than off the canvas (#341).
582            plot_area.y
583                + plot_area.height
584                + om.top
585                + crate::render::layout::PlotLayout::x_axis_band(theme, None).min(30.0),
586            true,
587        ),
588        LegendPosition::None => (0.0, 0.0, false),
589        LegendPosition::Inside(fx, fy) => {
590            let (legend_w, legend_h) = legend_size_hint;
591            // #377: inset by `legend_margin` from the panel edges, like
592            // every outside position, and place the *visible* box: the
593            // title is drawn centred on the origin's y, so its upper half
594            // (`lift`) sits above the origin.
595            let m = &theme.legend_margin;
596            let lift = title_lift;
597            let box_h = legend_h + lift;
598            let raw_x = plot_area.x + m.left + fx * (plot_area.width - m.left - m.right - legend_w);
599            // fy = 1 is the panel's top in ggplot-rs's convention (y grows
600            // down in SVG space, same flip the old formula already had).
601            let raw_top =
602                plot_area.y + m.top + (1.0 - fy) * (plot_area.height - m.top - m.bottom - box_h);
603            let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
604            let top = clamp_span(raw_top, box_h, canvas.y, canvas.y + canvas.height);
605            (x, top + lift, false)
606        }
607    }
608}
609
610/// How far an inside legend's title reaches above the legend origin (it is
611/// drawn centred on the origin's y): half the title's size when the first
612/// guide has a title, else nothing (#377).
613fn inside_title_lift(
614    scales: &ScaleSet,
615    representatives: &[Aesthetic],
616    theme: &Theme,
617    guide: &GuideLegend,
618) -> f64 {
619    let first = representatives
620        .iter()
621        .filter_map(|aes| scales.get(aes))
622        .find(|scale| !scale.breaks().is_empty());
623    match first {
624        Some(scale)
625            if theme.legend_title.visible
626                && !guide
627                    .title
628                    .as_deref()
629                    .unwrap_or_else(|| scale.name())
630                    .is_empty() =>
631        {
632            theme.legend_title.size / 2.0
633        }
634        _ => 0.0,
635    }
636}
637
638/// For an `Aesthetic::Size` scale, the largest mapped key's diameter
639/// across `breaks` (#236: ggplot2 draws each size key at its break's own
640/// mapped size and grows the key box to fit the largest one, rather than
641/// clamping every key to the fixed default swatch size — which made
642/// every key past a certain size draw identically, the reported bug).
643/// `0.0` for every other aesthetic, or if every mapped size already fits
644/// the theme's default `legend_key_width`, so a caller can just `.max()`
645/// it into the usual fixed size with no special-casing and no change to
646/// existing output when nothing needs the extra room.
647pub(crate) fn size_key_diameter(
648    aes: &Aesthetic,
649    scales: &ScaleSet,
650    scale: &dyn crate::scale::Scale,
651    breaks: &[(f64, String)],
652) -> f64 {
653    if *aes != Aesthetic::Size {
654        return 0.0;
655    }
656    let max_r = breaks
657        .iter()
658        .map(|(pos, label)| {
659            scale
660                .map_fraction_to_size(*pos)
661                .or_else(|| scales.map_size(&Value::Str(label.clone())))
662                .unwrap_or(3.0)
663        })
664        .fold(0.0_f64, f64::max);
665    max_r * 2.0
666}
667
668/// Draw a discrete legend at a given position. Returns the height used.
669#[allow(clippy::too_many_arguments)]
670/// Draw one discrete legend — or, when `group` has more than one entry
671/// (#239: guide merging), one *combined* legend whose every key overlays
672/// each aesthetic's own glyph at the same position (e.g. a filled swatch
673/// with a mapped-colour outline), the way ggplot2 merges `fill` and
674/// `colour` guides mapped to the same measure through the same scale
675/// spec into one legend instead of two. `group[0]` is the representative
676/// scale for the title, breaks and labels — `group_mergeable_legends`
677/// only ever groups aesthetics whose `breaks()` (and so their shared
678/// title) are already identical, so any member would do.
679fn draw_discrete_legend_at(
680    scales: &ScaleSet,
681    group: &[(Aesthetic, &dyn crate::scale::Scale)],
682    theme: &Theme,
683    legend_x: f64,
684    legend_y: f64,
685    backend: &mut dyn DrawBackend,
686    guide: &GuideLegend,
687    is_horizontal: bool,
688) -> Result<f64, RenderError> {
689    let (_, scale) = &group[0];
690    let mut breaks = scale.breaks();
691    if breaks.is_empty() {
692        return Ok(0.0);
693    }
694
695    // Apply guide reverse
696    if guide.is_reversed(&group[0].0) {
697        breaks.reverse();
698    }
699
700    // #236: a size legend's key box (and the row height it sits in) grows
701    // to fit its largest key — see `size_key_diameter`. A no-op (both
702    // `.max()`s keep the theme defaults) for every other aesthetic, and
703    // for a size legend whose largest key already fits them. At most one
704    // `group` member is ever `Aesthetic::Size` (`LEGEND_AESTHETICS` has
705    // only one), so taking the max across the group is exactly the same
706    // as the single-aesthetic case when `group.len() == 1`.
707    let diameter = group
708        .iter()
709        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
710        .fold(0.0_f64, f64::max);
711    let swatch_size = theme.legend_key_width.max(diameter);
712    let item_height = theme.legend_key_height.max(swatch_size);
713
714    // Draw legend title (guide title overrides scale name)
715    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
716    let legend_family = if theme.legend_title.family.is_empty() {
717        None
718    } else {
719        Some(theme.legend_title.family.clone())
720    };
721    let title_offset = if !title.is_empty() {
722        backend.set_element_class(Some("gg-legend gg-legend-title"));
723        crate::render::rich::draw(
724            backend,
725            theme.rich_text,
726            title,
727            (legend_x, legend_y),
728            &TextStyle {
729                color: theme.legend_title.color,
730                size: theme.legend_title.size,
731                anchor: TextAnchor::Start,
732                angle: 0.0,
733                family: legend_family,
734                face: theme.legend_title.face,
735                bg_colour: None,
736                bg_r: 0.0,
737            },
738        )?;
739        theme.legend_title.size + 4.0
740    } else {
741        0.0
742    };
743
744    let items_y = legend_y + title_offset;
745
746    // #413: `legend.key.spacing.y` between rows of keys.
747    let gap_y = theme.legend_key_gap_y();
748    let keys_height = breaks.len() as f64 * item_height + (breaks.len() - 1) as f64 * gap_y;
749
750    // Draw legend background — one row of keys when horizontal, advancing
751    // exactly as the keys below do.
752    if theme.legend_background.visible {
753        let (total_width, total_height) = if is_horizontal {
754            let row: f64 = breaks
755                .iter()
756                .map(|(_, label)| {
757                    swatch_size
758                        + theme.legend_spacing
759                        + (label.chars().count() as f64) * theme.legend_text.size * 0.62
760                        + theme
761                            .legend_key_spacing_x
762                            .unwrap_or(theme.legend_spacing * 3.0)
763                })
764                .sum();
765            (row, item_height)
766        } else {
767            (
768                swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0,
769                keys_height,
770            )
771        };
772        if let Some(fill) = theme.legend_background.fill {
773            backend.set_element_class(Some("gg-legend gg-legend-background"));
774            backend.draw_rect(
775                (legend_x - 2.0, items_y - 2.0),
776                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
777                &RectStyle {
778                    fill: Some(fill),
779                    stroke: theme.legend_background.color,
780                    stroke_width: theme.legend_background.width,
781                    alpha: 1.0,
782                    clip: false,
783                    corner_radius: 0.0,
784                },
785            )?;
786        }
787    }
788
789    // A binned scale's `guide_bins()` (#409): keys edge to edge, labelled
790    // at the bin boundaries.
791    if let Some(bins) = guide.bins_for(&group[0].0, *scale) {
792        if let Some(mut edges) = scale
793            .bin_edge_labels()
794            .filter(|e| e.len() == breaks.len() + 1)
795        {
796            if guide.is_reversed(&group[0].0) {
797                edges.reverse();
798            }
799            let extent = draw_bins_keys(
800                scales,
801                group,
802                theme,
803                guide,
804                &bins,
805                &breaks,
806                &edges,
807                (legend_x, items_y),
808                swatch_size,
809                item_height,
810                backend,
811                is_horizontal,
812            )?;
813            return Ok(if is_horizontal {
814                extent
815            } else {
816                title_offset + extent
817            });
818        }
819    }
820
821    // Horizontal legends lay keys left-to-right; vertical ones stack downward.
822    let mut cur_x = legend_x;
823    for (i, (pos, label)) in breaks.iter().enumerate() {
824        let x = if is_horizontal { cur_x } else { legend_x };
825        let y = if is_horizontal {
826            items_y
827        } else {
828            items_y + i as f64 * (item_height + gap_y)
829        };
830        let center_x = x + swatch_size / 2.0;
831        let center_y = y + swatch_size / 2.0;
832
833        // Draw legend key background
834        if theme.legend_key.visible {
835            if let Some(fill) = theme.legend_key.fill {
836                backend.set_element_class(Some("gg-legend gg-legend-key"));
837                backend.draw_rect(
838                    (x, y),
839                    (x + swatch_size, y + swatch_size),
840                    &RectStyle {
841                        fill: Some(fill),
842                        stroke: theme.legend_key.color,
843                        stroke_width: theme.legend_key.width,
844                        alpha: 1.0,
845                        clip: false,
846                        corner_radius: 0.0,
847                    },
848                )?;
849            }
850        }
851
852        // Draw every group member's own glyph, overlaid at the same
853        // position (#239) — a single swatch for the common, unmerged
854        // case (`group.len() == 1`).
855        backend.set_element_class(Some("gg-legend gg-legend-key"));
856        draw_group_keys(
857            backend,
858            scales,
859            group,
860            theme,
861            guide,
862            label,
863            *pos,
864            (x, y, center_x, center_y),
865            swatch_size,
866        )?;
867
868        // Label
869        let label_family = if theme.legend_text.family.is_empty() {
870            None
871        } else {
872            Some(theme.legend_text.family.clone())
873        };
874        backend.set_element_class(Some("gg-legend gg-legend-text"));
875        crate::render::rich::draw(
876            backend,
877            theme.rich_text,
878            label,
879            (x + swatch_size + theme.legend_spacing, center_y),
880            &TextStyle {
881                color: theme.legend_text.color,
882                size: theme.legend_text.size,
883                anchor: TextAnchor::Start,
884                angle: 0.0,
885                family: label_family,
886                face: theme.legend_text.face,
887                bg_colour: None,
888                bg_r: 0.0,
889            },
890        )?;
891        if is_horizontal {
892            // advance past swatch + spacing + estimated label width + gap
893            cur_x = x
894                + swatch_size
895                + theme.legend_spacing
896                + (label.chars().count() as f64) * theme.legend_text.size * 0.62
897                + theme
898                    .legend_key_spacing_x
899                    .unwrap_or(theme.legend_spacing * 3.0);
900        }
901    }
902
903    if is_horizontal {
904        Ok(cur_x - legend_x) // width used
905    } else {
906        Ok(title_offset + keys_height)
907    }
908}
909
910/// `guide_bins()`'s keys (#409, ggplot2's default guide for a binned
911/// scale): one key per bin, edge to edge, with `edges` (one more than
912/// `breaks`) labelled at the boundaries — the outer two only with
913/// `show_limits` — beside an axis line with a tick at each boundary.
914/// Vertical keys run down from the smallest bin with the labels to their
915/// right; horizontal ones run left to right with the labels below. Returns
916/// the keys' extent along the legend (height, or width when horizontal).
917#[allow(clippy::too_many_arguments)]
918fn draw_bins_keys(
919    scales: &ScaleSet,
920    group: &[(Aesthetic, &dyn crate::scale::Scale)],
921    theme: &Theme,
922    guide: &GuideLegend,
923    bins: &crate::guide::config::GuideBins,
924    breaks: &[(f64, String)],
925    edges: &[String],
926    (x0, y0): (f64, f64),
927    swatch_size: f64,
928    item_height: f64,
929    backend: &mut dyn DrawBackend,
930    is_horizontal: bool,
931) -> Result<f64, RenderError> {
932    let n = breaks.len();
933    let text_size = theme.legend_text.size;
934    // Horizontal cells widen to fit their boundary labels.
935    let pitch = if is_horizontal {
936        let widest = edges
937            .iter()
938            .map(|l| l.chars().count() as f64 * text_size * 0.62)
939            .fold(0.0_f64, f64::max);
940        swatch_size.max(widest + theme.legend_spacing)
941    } else {
942        item_height
943    };
944    for (i, (pos, label)) in breaks.iter().enumerate() {
945        let (cell_x, cell_y) = if is_horizontal {
946            (x0 + i as f64 * pitch, y0)
947        } else {
948            (x0, y0 + i as f64 * pitch)
949        };
950        let (cell_w, cell_h) = if is_horizontal {
951            (pitch, swatch_size)
952        } else {
953            (swatch_size, pitch)
954        };
955        if theme.legend_key.visible {
956            if let Some(fill) = theme.legend_key.fill {
957                backend.set_element_class(Some("gg-legend gg-legend-key"));
958                backend.draw_rect(
959                    (cell_x, cell_y),
960                    (cell_x + cell_w, cell_y + cell_h),
961                    &RectStyle {
962                        fill: Some(fill),
963                        stroke: theme.legend_key.color,
964                        stroke_width: theme.legend_key.width,
965                        alpha: 1.0,
966                        clip: false,
967                        corner_radius: 0.0,
968                    },
969                )?;
970            }
971        }
972        // The square key glyph, centred in its cell.
973        let x = cell_x + (cell_w - swatch_size) / 2.0;
974        let y = cell_y + (cell_h - swatch_size) / 2.0;
975        backend.set_element_class(Some("gg-legend gg-legend-key"));
976        draw_group_keys(
977            backend,
978            scales,
979            group,
980            theme,
981            guide,
982            label,
983            *pos,
984            (x, y, x + swatch_size / 2.0, y + swatch_size / 2.0),
985            swatch_size,
986        )?;
987    }
988
989    let length = n as f64 * pitch;
990    let tick = (swatch_size * 0.2).max(2.0);
991    let line_style = LineStyle {
992        color: theme.legend_text.color,
993        width: 0.5,
994        alpha: 1.0,
995        linetype: Linetype::Solid,
996        ..Default::default()
997    };
998    if bins.axis {
999        let axis = if is_horizontal {
1000            let ay = y0 + swatch_size;
1001            [(x0, ay), (x0 + length, ay)]
1002        } else {
1003            let ax = x0 + swatch_size;
1004            [(ax, y0), (ax, y0 + length)]
1005        };
1006        backend.set_element_class(Some("gg-legend gg-legend-tick"));
1007        backend.draw_line(&axis, &line_style)?;
1008    }
1009    let family = if theme.legend_text.family.is_empty() {
1010        None
1011    } else {
1012        Some(theme.legend_text.family.clone())
1013    };
1014    for (j, label) in edges.iter().enumerate() {
1015        let at = j as f64 * pitch;
1016        if bins.axis {
1017            let t = if is_horizontal {
1018                let (x, ay) = (x0 + at, y0 + swatch_size);
1019                [(x, ay), (x, ay - tick)]
1020            } else {
1021                let (ax, y) = (x0 + swatch_size, y0 + at);
1022                [(ax, y), (ax - tick, y)]
1023            };
1024            backend.set_element_class(Some("gg-legend gg-legend-tick"));
1025            backend.draw_line(&t, &line_style)?;
1026        }
1027        if (j == 0 || j == n) && !bins.show_limits {
1028            continue;
1029        }
1030        let (pos, anchor) = if is_horizontal {
1031            (
1032                (x0 + at, y0 + swatch_size + 2.0 + text_size * 0.6),
1033                TextAnchor::Middle,
1034            )
1035        } else {
1036            (
1037                (x0 + swatch_size + theme.legend_spacing, y0 + at),
1038                TextAnchor::Start,
1039            )
1040        };
1041        backend.set_element_class(Some("gg-legend gg-legend-text"));
1042        crate::render::rich::draw(
1043            backend,
1044            theme.rich_text,
1045            label,
1046            pos,
1047            &TextStyle {
1048                color: theme.legend_text.color,
1049                size: text_size,
1050                anchor,
1051                angle: 0.0,
1052                family: family.clone(),
1053                face: theme.legend_text.face,
1054                bg_colour: None,
1055                bg_r: 0.0,
1056            },
1057        )?;
1058    }
1059    Ok(length)
1060}
1061
1062/// #211: the bar's own `(length, thickness)` in pixels for a continuous
1063/// colorbar — vertical keeps the original tall-and-narrow dims
1064/// (`legend_key_width` thick, `legend_key_height * 8.0` long); horizontal
1065/// swaps them (ggplot2's `guide_colourbar(direction = "horizontal")`),
1066/// so the bar is wide-and-short instead. Shared by
1067/// [`draw_continuous_legend_at`] (the real draw) and
1068/// [`continuous_horizontal_bar_height`] (the layout-time estimate), so
1069/// the two can't disagree.
1070/// `bar`'s `bar_width` / `bar_height` (#409) override the x / y extent.
1071pub(crate) fn continuous_bar_dims(
1072    theme: &Theme,
1073    is_horizontal: bool,
1074    bar: &GuideColourbar,
1075) -> (f64, f64) {
1076    let (w, h) = if is_horizontal {
1077        (theme.legend_key_height * 8.0, theme.legend_key_width)
1078    } else {
1079        (theme.legend_key_width, theme.legend_key_height * 8.0)
1080    };
1081    (
1082        bar.bar_width
1083            .filter(|v| v.is_finite() && *v > 0.0)
1084            .unwrap_or(w),
1085        bar.bar_height
1086            .filter(|v| v.is_finite() && *v > 0.0)
1087            .unwrap_or(h),
1088    )
1089}
1090
1091/// #211: the total height a *horizontal* continuous colorbar consumes —
1092/// title line (if `has_title`) + bar thickness + its tick marks + one
1093/// line of labels below. Used by `plot.rs` to reserve the Top/Bottom
1094/// legend band's height before layout runs; mirrors
1095/// [`draw_continuous_legend_at`]'s own math exactly so the reserved band
1096/// and the actual draw can't disagree.
1097pub(crate) fn continuous_horizontal_bar_height(
1098    theme: &Theme,
1099    has_title: bool,
1100    bar: &GuideColourbar,
1101) -> f64 {
1102    let title_offset = if has_title {
1103        theme.legend_title.size + 4.0
1104    } else {
1105        0.0
1106    };
1107    let (_, bar_thickness) = continuous_bar_dims(theme, true, bar);
1108    let tick_len = theme.legend_ticks_length;
1109    let label_line = theme.legend_text.size + 4.0;
1110    title_offset + bar_thickness + tick_len + theme.legend_spacing + label_line
1111}
1112
1113/// Draw a continuous colorbar legend at a given position. `is_horizontal`
1114/// (#211) draws it wide × short — bar slices left to right from the
1115/// scale's min to max, ticks/labels below the bar, matching ggplot2's
1116/// `guide_colourbar(direction = "horizontal")` — instead of the default
1117/// tall × narrow bar with ticks/labels to its right. Returns the height
1118/// used.
1119fn draw_continuous_legend_at(
1120    scale: &dyn crate::scale::Scale,
1121    theme: &Theme,
1122    legend_x: f64,
1123    legend_y: f64,
1124    backend: &mut dyn DrawBackend,
1125    guide: &GuideLegend,
1126    is_horizontal: bool,
1127) -> Result<f64, RenderError> {
1128    let breaks = scale.breaks();
1129    if breaks.is_empty() {
1130        return Ok(0.0);
1131    }
1132
1133    let bar = guide.colourbar_for(&scale.aesthetic());
1134    let (bar_width, bar_height) = continuous_bar_dims(theme, is_horizontal, &bar);
1135
1136    // Draw legend title (guide title overrides scale name)
1137    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1138    let cont_family = if theme.legend_title.family.is_empty() {
1139        None
1140    } else {
1141        Some(theme.legend_title.family.clone())
1142    };
1143    let title_offset = if !title.is_empty() {
1144        backend.set_element_class(Some("gg-legend gg-legend-title"));
1145        crate::render::rich::draw(
1146            backend,
1147            theme.rich_text,
1148            title,
1149            (legend_x, legend_y),
1150            &TextStyle {
1151                color: theme.legend_title.color,
1152                size: theme.legend_title.size,
1153                anchor: TextAnchor::Start,
1154                angle: 0.0,
1155                family: cont_family,
1156                face: theme.legend_title.face,
1157                bg_colour: None,
1158                bg_r: 0.0,
1159            },
1160        )?;
1161        theme.legend_title.size + 4.0
1162    } else {
1163        0.0
1164    };
1165
1166    let bar_top = legend_y + title_offset;
1167
1168    // Draw legend background
1169    if theme.legend_background.visible {
1170        let total_width = if is_horizontal {
1171            bar_width
1172        } else {
1173            bar_width + theme.legend_spacing + theme.legend_text.size * 6.0
1174        };
1175        let total_height = if is_horizontal {
1176            bar_height + theme.legend_spacing + theme.legend_text.size * 2.0
1177        } else {
1178            bar_height
1179        };
1180        if let Some(fill) = theme.legend_background.fill {
1181            backend.set_element_class(Some("gg-legend gg-legend-background"));
1182            backend.draw_rect(
1183                (legend_x - 2.0, bar_top - 2.0),
1184                (legend_x + total_width + 2.0, bar_top + total_height + 2.0),
1185                &RectStyle {
1186                    fill: Some(fill),
1187                    stroke: theme.legend_background.color,
1188                    stroke_width: theme.legend_background.width,
1189                    alpha: 1.0,
1190                    clip: false,
1191                    corner_radius: 0.0,
1192                },
1193            )?;
1194        }
1195    }
1196
1197    // Draw the bar along its length — max at the top when vertical, min on
1198    // the left (like an x-axis) when horizontal. Use data-domain values to
1199    // avoid double-normalization in map_to_color().
1200    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
1201    let at = |t: f64| data_min + t * (data_max - data_min);
1202    // Coloursteps (#409): the label positions along the bar, when they
1203    // differ from the breaks' own (even steps).
1204    let mut step_labels: Option<Vec<(f64, String)>> = None;
1205    if !bar.steps && bar.display == ColourbarDisplay::Gradient {
1206        // A smooth ramp: `nbin` thin slices, each overlapping the next by
1207        // half a pixel so no seam shows.
1208        let n_slices = bar.nbin.max(1);
1209        if is_horizontal {
1210            let slice_width = bar_width / n_slices as f64;
1211            for i in 0..n_slices {
1212                let t = i as f64 / n_slices as f64;
1213                let color = scale
1214                    .map_to_color(&Value::Float(at(t)))
1215                    .unwrap_or((127, 127, 127));
1216                let sx = legend_x + i as f64 * slice_width;
1217                backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
1218                backend.draw_rect(
1219                    (sx, bar_top),
1220                    (sx + slice_width + 0.5, bar_top + bar_height),
1221                    &RectStyle {
1222                        fill: Some(color),
1223                        stroke: None,
1224                        stroke_width: 0.0,
1225                        alpha: 1.0,
1226                        clip: false,
1227                        corner_radius: 0.0,
1228                    },
1229                )?;
1230            }
1231        } else {
1232            let slice_height = bar_height / n_slices as f64;
1233            for i in 0..n_slices {
1234                let t = 1.0 - i as f64 / n_slices as f64;
1235                let color = scale
1236                    .map_to_color(&Value::Float(at(t)))
1237                    .unwrap_or((127, 127, 127));
1238                let sy = bar_top + i as f64 * slice_height;
1239                backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
1240                backend.draw_rect(
1241                    (legend_x, sy),
1242                    (legend_x + bar_width, sy + slice_height + 0.5),
1243                    &RectStyle {
1244                        fill: Some(color),
1245                        stroke: None,
1246                        stroke_width: 0.0,
1247                        alpha: 1.0,
1248                        clip: false,
1249                        corner_radius: 0.0,
1250                    },
1251                )?;
1252            }
1253        }
1254    } else {
1255        // Separate blocks, edge to edge: `nbin` rectangles, or one per
1256        // interval between breaks (coloursteps). Each is (start, end)
1257        // fractions of the bar from its min end, painted at one value.
1258        let blocks: Vec<(f64, f64, f64)> = if bar.steps {
1259            let mut edges: Vec<(f64, Option<String>)> = vec![(0.0, None)];
1260            for (pos, label) in &breaks {
1261                if *pos > 1e-9 && *pos < 1.0 - 1e-9 {
1262                    edges.push((*pos, Some(label.clone())));
1263                }
1264            }
1265            edges.push((1.0, None));
1266            let n = edges.len() - 1;
1267            let place = |i: usize| {
1268                if bar.even_steps {
1269                    i as f64 / n as f64
1270                } else {
1271                    edges[i].0
1272                }
1273            };
1274            let mut labels: Vec<(f64, String)> = Vec::new();
1275            for (i, (_, l)) in edges.iter().enumerate() {
1276                match l {
1277                    Some(l) => labels.push((place(i), l.clone())),
1278                    None if bar.show_limits => {
1279                        let v = if i == 0 { data_min } else { data_max };
1280                        labels.push((place(i), crate::scale::util::format_number_opt(v, None)));
1281                    }
1282                    None => {}
1283                }
1284            }
1285            step_labels = Some(labels);
1286            (0..n)
1287                .map(|i| {
1288                    (
1289                        place(i),
1290                        place(i + 1),
1291                        at((edges[i].0 + edges[i + 1].0) / 2.0),
1292                    )
1293                })
1294                .collect()
1295        } else {
1296            let n = bar.nbin.max(1);
1297            (0..n)
1298                .map(|i| {
1299                    let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);
1300                    (t0, t1, at((t0 + t1) / 2.0))
1301                })
1302                .collect()
1303        };
1304        for &(t0, t1, value) in &blocks {
1305            let color = scale
1306                .map_to_color(&Value::Float(value))
1307                .unwrap_or((127, 127, 127));
1308            let (p0, p1) = if is_horizontal {
1309                (
1310                    (legend_x + t0 * bar_width, bar_top),
1311                    (legend_x + t1 * bar_width, bar_top + bar_height),
1312                )
1313            } else {
1314                (
1315                    (legend_x, bar_top + (1.0 - t1) * bar_height),
1316                    (legend_x + bar_width, bar_top + (1.0 - t0) * bar_height),
1317                )
1318            };
1319            backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
1320            backend.draw_rect(
1321                p0,
1322                p1,
1323                &RectStyle {
1324                    fill: Some(color),
1325                    stroke: None,
1326                    stroke_width: 0.0,
1327                    alpha: 1.0,
1328                    clip: false,
1329                    corner_radius: 0.0,
1330                },
1331            )?;
1332        }
1333    }
1334
1335    // Draw border
1336    // (Ticks reuse its colour and width even when the frame is hidden.)
1337    let border = theme.legend_bar_border_line();
1338    let border_style = LineStyle {
1339        color: border.color,
1340        width: border.width,
1341        alpha: 1.0,
1342        linetype: border.linetype,
1343        ..Default::default()
1344    };
1345    if bar.frame.unwrap_or(border.visible) {
1346        backend.set_element_class(Some("gg-legend gg-colourbar-frame"));
1347        backend.draw_line(
1348            &[
1349                (legend_x, bar_top),
1350                (legend_x + bar_width, bar_top),
1351                (legend_x + bar_width, bar_top + bar_height),
1352                (legend_x, bar_top + bar_height),
1353                (legend_x, bar_top),
1354            ],
1355            &border_style,
1356        )?;
1357    }
1358
1359    // Draw tick marks and labels: to the bar's right (vertical, pos=1 at
1360    // the top) or below it (horizontal, pos=0 on the left), matching the
1361    // slice order drawn above.
1362    let tick_len = theme.legend_ticks_length;
1363    let tick_family = if theme.legend_text.family.is_empty() {
1364        None
1365    } else {
1366        Some(theme.legend_text.family.clone())
1367    };
1368    let labels = step_labels.unwrap_or(breaks);
1369    for (pos, label) in &labels {
1370        if is_horizontal {
1371            let tick_x = legend_x + bar_width * pos;
1372            if bar.ticks {
1373                backend.set_element_class(Some("gg-legend gg-legend-tick"));
1374                backend.draw_line(
1375                    &[
1376                        (tick_x, bar_top + bar_height),
1377                        (tick_x, bar_top + bar_height + tick_len),
1378                    ],
1379                    &border_style,
1380                )?;
1381            }
1382            backend.set_element_class(Some("gg-legend gg-legend-text"));
1383            crate::render::rich::draw(
1384                backend,
1385                theme.rich_text,
1386                label,
1387                (
1388                    tick_x,
1389                    bar_top + bar_height + tick_len + theme.legend_text.size,
1390                ),
1391                &TextStyle {
1392                    color: theme.legend_text.color,
1393                    size: theme.legend_text.size,
1394                    anchor: TextAnchor::Middle,
1395                    angle: 0.0,
1396                    family: tick_family.clone(),
1397                    face: theme.legend_text.face,
1398                    bg_colour: None,
1399                    bg_r: 0.0,
1400                },
1401            )?;
1402        } else {
1403            let tick_y = bar_top + bar_height * (1.0 - pos);
1404            if bar.ticks {
1405                backend.set_element_class(Some("gg-legend gg-legend-tick"));
1406                backend.draw_line(
1407                    &[
1408                        (legend_x + bar_width, tick_y),
1409                        (legend_x + bar_width + tick_len, tick_y),
1410                    ],
1411                    &border_style,
1412                )?;
1413            }
1414            backend.set_element_class(Some("gg-legend gg-legend-text"));
1415            crate::render::rich::draw(
1416                backend,
1417                theme.rich_text,
1418                label,
1419                (
1420                    legend_x + bar_width + tick_len + theme.legend_spacing,
1421                    tick_y,
1422                ),
1423                &TextStyle {
1424                    color: theme.legend_text.color,
1425                    size: theme.legend_text.size,
1426                    anchor: TextAnchor::Start,
1427                    angle: 0.0,
1428                    family: tick_family.clone(),
1429                    face: theme.legend_text.face,
1430                    bg_colour: None,
1431                    bg_r: 0.0,
1432                },
1433            )?;
1434        }
1435    }
1436
1437    if is_horizontal {
1438        Ok(continuous_horizontal_bar_height(
1439            theme,
1440            !title.is_empty(),
1441            &bar,
1442        ))
1443    } else {
1444        Ok(title_offset + bar_height)
1445    }
1446}
1447
1448/// Draw one legend key's swatch (not its label) for `aes`, at `(x, y)`
1449/// (top-left) / `(center_x, center_y)` (centre), `swatch_size` square.
1450/// Shared by the unchanged [`draw_discrete_legend_at`] and the B3-2
1451/// planner-driven [`draw_legend_planned`], so both draw pixel-identical
1452/// swatches.
1453///
1454/// A key with no aesthetic-mapped colour of its own (`Shape`, `Linetype`,
1455/// `Size`, `Alpha`, `Linewidth` — `Color`/`Fill` always have one, from
1456/// `scales.map_color`) falls back to `theme.geom.ink`, then
1457/// `theme.legend_text.color` (#237: a *fixed* dark grey here was almost
1458/// invisible on a dark panel in a theme that sets `legend_text.color`
1459/// for readability but never thought to also set the less commonly used
1460/// `geom.ink`) — the same two-tier fallback `GeomPoint`/`GeomText`'s own
1461/// `apply_element_geom` effectively uses for an unmapped panel mark
1462/// (`ink` if the theme sets it, otherwise its own default), extended one
1463/// more tier since a key glyph has no mapped colour of its own to start
1464/// from the way a panel mark's default colour does. **Visible default
1465/// change:** the base theme's `legend_text.color` is pure black
1466/// (`(0, 0, 0)`), not the old fallback's dark grey (`(50, 50, 50)`) — a
1467/// small, intentional difference (documented in the CHANGELOG), not a
1468/// behaviour-preserving refactor.
1469#[allow(clippy::too_many_arguments)]
1470fn draw_legend_swatch(
1471    backend: &mut dyn DrawBackend,
1472    scales: &ScaleSet,
1473    aes: &Aesthetic,
1474    scale: &dyn crate::scale::Scale,
1475    theme: &Theme,
1476    label: &str,
1477    pos: f64,
1478    x: f64,
1479    y: f64,
1480    center_x: f64,
1481    center_y: f64,
1482    swatch_size: f64,
1483) -> Result<(), RenderError> {
1484    let value = Value::Str(label.to_string());
1485    match aes {
1486        Aesthetic::Color | Aesthetic::Fill => {
1487            let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
1488            let style = RectStyle {
1489                fill: Some(color),
1490                stroke: None,
1491                stroke_width: 0.0,
1492                alpha: 1.0,
1493                clip: false,
1494                corner_radius: 0.0,
1495            };
1496            // #206: the "no data" key draws its scale's na_pattern
1497            // instead of a flat swatch, when set.
1498            if scale.is_na_break(label) {
1499                if let Some(p) = scale.na_fill_pattern() {
1500                    return backend.draw_rect_pattern(
1501                        (x, y),
1502                        (x + swatch_size, y + swatch_size),
1503                        &p,
1504                        &style,
1505                    );
1506                }
1507            }
1508            backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
1509        }
1510        Aesthetic::Shape => {
1511            let shape = scales
1512                .map_shape(&value)
1513                .unwrap_or(crate::render::backend::PointShape::Circle);
1514            backend.draw_shape(
1515                (center_x, center_y),
1516                swatch_size / 3.0,
1517                &PointStyle {
1518                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1519                    alpha: 1.0,
1520                    filled: true,
1521                    shape,
1522                },
1523            )?;
1524        }
1525        Aesthetic::Linetype => {
1526            let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
1527            backend.draw_line(
1528                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1529                &LineStyle {
1530                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1531                    width: 1.5,
1532                    alpha: 1.0,
1533                    linetype: lt,
1534                    ..Default::default()
1535                },
1536            )?;
1537        }
1538        Aesthetic::Size => {
1539            let size = scale
1540                .map_fraction_to_size(pos)
1541                .or_else(|| scales.map_size(&value))
1542                .unwrap_or(3.0);
1543            backend.draw_shape(
1544                (center_x, center_y),
1545                size.min(swatch_size / 2.0),
1546                &PointStyle {
1547                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1548                    alpha: 1.0,
1549                    filled: true,
1550                    shape: crate::render::backend::PointShape::Circle,
1551                },
1552            )?;
1553        }
1554        Aesthetic::Alpha => {
1555            let alpha = scale
1556                .map_fraction_to_alpha(pos)
1557                .or_else(|| scales.map_alpha(&value))
1558                .unwrap_or(1.0);
1559            backend.draw_rect(
1560                (x, y),
1561                (x + swatch_size, y + swatch_size),
1562                &RectStyle {
1563                    fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
1564                    stroke: None,
1565                    stroke_width: 0.0,
1566                    alpha,
1567                    clip: false,
1568                    corner_radius: 0.0,
1569                },
1570            )?;
1571        }
1572        Aesthetic::Linewidth => {
1573            let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
1574            backend.draw_line(
1575                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1576                &LineStyle {
1577                    color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1578                    width: width.min(swatch_size),
1579                    alpha: 1.0,
1580                    linetype: Linetype::Solid,
1581                    ..Default::default()
1582                },
1583            )?;
1584        }
1585        _ => {}
1586    }
1587    Ok(())
1588}
1589
1590/// Draw one legend key for `group` (#410): every layer that shows one of the
1591/// group's aesthetics draws its own glyph ([`crate::guide::key::draw_key`]),
1592/// overlaid in layer order — a line layer and a point layer on one colour
1593/// mapping share one key. Layers without a glyph (and plots with no layer
1594/// info) draw the per-aesthetic swatch as before.
1595#[allow(clippy::too_many_arguments)]
1596fn draw_group_keys(
1597    backend: &mut dyn DrawBackend,
1598    scales: &ScaleSet,
1599    group: &[(Aesthetic, &dyn crate::scale::Scale)],
1600    theme: &Theme,
1601    guide: &GuideLegend,
1602    label: &str,
1603    pos: f64,
1604    (x, y, center_x, center_y): (f64, f64, f64, f64),
1605    swatch_size: f64,
1606) -> Result<(), RenderError> {
1607    use crate::guide::key::{draw_key, KeyProps};
1608    let in_group = |a: &Aesthetic| group.iter().any(|(g, _)| g == a);
1609    let layers: Vec<_> = guide
1610        .layer_keys
1611        .iter()
1612        .filter(|k| k.aesthetics.iter().any(in_group))
1613        .collect();
1614    let legacy = |backend: &mut dyn DrawBackend, only: Option<&[Aesthetic]>| {
1615        for (member_aes, member_scale) in group {
1616            if only.is_some_and(|o| !o.contains(member_aes)) {
1617                continue;
1618            }
1619            draw_legend_swatch(
1620                backend,
1621                scales,
1622                member_aes,
1623                *member_scale,
1624                theme,
1625                label,
1626                pos,
1627                x,
1628                y,
1629                center_x,
1630                center_y,
1631                swatch_size,
1632            )?;
1633        }
1634        Ok::<(), RenderError>(())
1635    };
1636    if layers.iter().all(|k| k.glyph.is_none()) {
1637        return legacy(backend, None);
1638    }
1639    let value = Value::Str(label.to_string());
1640    let ink = theme.geom.ink.unwrap_or(theme.legend_text.color);
1641    for k in layers {
1642        let Some(glyph) = k.glyph else {
1643            legacy(backend, Some(&k.aesthetics))?;
1644            continue;
1645        };
1646        let mut props = KeyProps::default();
1647        for (aes, scale) in group {
1648            if !k.aesthetics.contains(aes) {
1649                continue;
1650            }
1651            match aes {
1652                Aesthetic::Color => props.color = scales.map_color(aes, &value),
1653                Aesthetic::Fill => props.fill = scales.map_color(aes, &value),
1654                Aesthetic::Shape => props.shape = scales.map_shape(&value),
1655                Aesthetic::Linetype => props.linetype = scales.map_linetype(&value),
1656                Aesthetic::Size => {
1657                    props.size = scale
1658                        .map_fraction_to_size(pos)
1659                        .or_else(|| scales.map_size(&value))
1660                }
1661                Aesthetic::Alpha => {
1662                    props.alpha = scale
1663                        .map_fraction_to_alpha(pos)
1664                        .or_else(|| scales.map_alpha(&value))
1665                }
1666                Aesthetic::Linewidth => props.linewidth = scale.map_fraction_to_size(pos),
1667                _ => {}
1668            }
1669        }
1670        // #409: `override.aes` for any of this legend's aesthetics wins.
1671        for (aes, _) in group {
1672            if let Some((_, o)) = guide.overrides.iter().find(|(a, _)| a == aes) {
1673                props.color = o.color.or(props.color);
1674                props.fill = o.fill.or(props.fill);
1675                props.shape = o.shape.or(props.shape);
1676                props.linetype = o.linetype.clone().or(props.linetype);
1677                props.size = o.size.or(props.size);
1678                props.alpha = o.alpha.or(props.alpha);
1679                props.linewidth = o.linewidth.or(props.linewidth);
1680            }
1681        }
1682        draw_key(backend, glyph, &props, ink, (x, y, swatch_size))?;
1683    }
1684    Ok(())
1685}
1686
1687// ─── B3-2: nrow/ncol/byrow, auto-wrap, "+N more" ───────────────────────────
1688
1689/// One legend key's grid placement, in the planner's own pixel space
1690/// (relative to the legend's own origin — `row` is 0-indexed top row,
1691/// `x_offset` is the horizontal pitch from the first column).
1692#[derive(Clone, Debug)]
1693pub(crate) struct LegendEntry {
1694    pub break_idx: usize,
1695    pub row: usize,
1696    pub x_offset: f64,
1697    /// A truncated-with-ellipsis display label (#159), set only when a
1698    /// vertical multi-column grid still doesn't fit the canvas after
1699    /// reducing its column count as far as it can go: the full label is
1700    /// still available (e.g. for a `<title>` tooltip) via the scale's own
1701    /// `breaks()[break_idx]`. `None` draws the break's label unchanged —
1702    /// the common, byte-identical case.
1703    pub label_override: Option<String>,
1704}
1705
1706/// A trailing "+{k} more" marker when the legend doesn't have room for
1707/// every key (D7). Not a mark (`draw_text` only — no swatch, no
1708/// `data-geom`/class).
1709#[derive(Clone, Debug)]
1710pub(crate) struct LegendOverflow {
1711    pub dropped: usize,
1712    pub row: usize,
1713    pub x_offset: f64,
1714    pub text: String,
1715}
1716
1717/// A non-default legend layout (B3-2): an explicit `nrow`/`ncol` grid, or
1718/// an auto-wrapped horizontal legend. `None` from [`plan_legend`] means
1719/// "nothing changes" — the caller keeps the 0.21 single-row/single-column
1720/// path verbatim, so a plot that doesn't need this is byte-identical.
1721#[derive(Clone, Debug)]
1722pub(crate) struct LegendPlan {
1723    pub rows: usize,
1724    pub entries: Vec<LegendEntry>,
1725    pub overflow: Option<LegendOverflow>,
1726    /// Columns in an explicit grid (always 1 for the horizontal auto-wrap
1727    /// path, which never adds columns). Review #158 finding 3: a vertical
1728    /// legend with `ncol > 1` needs `(cols − 1) · col_pitch` of extra
1729    /// width reserved, the same way extra rows are reserved for height.
1730    pub cols: usize,
1731    /// The per-column horizontal pitch an explicit grid uses (0 for the
1732    /// auto-wrap path, which doesn't have columns in this sense).
1733    pub col_pitch: f64,
1734}
1735
1736/// Per-key horizontal pitch (swatch + spacing + label + gap): the larger of
1737/// today's crude per-character advance (`draw_discrete_legend_at`'s
1738/// `cur_x` formula) and a `text_width`-based measurement, so a label that
1739/// ties under both estimates stays on the byte-identical single-row path.
1740fn legend_pitch(label: &str, theme: &Theme) -> f64 {
1741    let swatch = theme.legend_key_width;
1742    let spacing = theme.legend_spacing;
1743    let text_size = theme.legend_text.size;
1744    // #413: `legend.key.spacing.x` replaces the gaps after the label.
1745    let gap = theme.legend_key_spacing_x;
1746    let legacy = swatch
1747        + spacing
1748        + (label.chars().count() as f64) * text_size * 0.62
1749        + gap.unwrap_or(spacing * 3.0);
1750    let measured =
1751        swatch + spacing + crate::render::text::text_width(label, text_size) + gap.unwrap_or(8.0);
1752    legacy.max(measured)
1753}
1754
1755/// Plan a non-default legend layout (B3-2, D7). `avail_width` is the
1756/// horizontal room for a horizontal legend's flow-pack / overflow check
1757/// (`plot_area.x + plot_area.width - legend_x`); `avail_height` only feeds
1758/// the default `max_rows` formula. Returns `None` when nothing changes:
1759/// no explicit `nrow`/`ncol`, and (for a horizontal legend) the one-line
1760/// layout already fits — or the legend is vertical, where auto-wrap never
1761/// applies (D7 is horizontal-only; `nrow`/`ncol` still work on either).
1762pub(crate) fn plan_legend(
1763    breaks: &[(f64, String)],
1764    guide: &GuideLegend,
1765    theme: &Theme,
1766    avail_width: f64,
1767    avail_height: f64,
1768    is_horizontal: bool,
1769) -> Option<LegendPlan> {
1770    let n = breaks.len();
1771    if n == 0 {
1772        return None;
1773    }
1774
1775    // Explicit grid: ggplot2's `nrow`/`ncol`, column-major unless `byrow`.
1776    if guide.nrow.is_some() || guide.ncol.is_some() {
1777        let (rows, cols) = match (guide.nrow, guide.ncol) {
1778            (Some(nrow), Some(ncol)) => (nrow, ncol),
1779            (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
1780            (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
1781            (None, None) => unreachable!("checked above"),
1782        };
1783        let rows = rows.max(1);
1784        let cols = cols.max(1);
1785        let col_pitch = breaks
1786            .iter()
1787            .map(|(_, l)| legend_pitch(l, theme))
1788            .fold(0.0_f64, f64::max);
1789        let entries = (0..n)
1790            .map(|i| {
1791                let (row, col) = if guide.byrow {
1792                    (i / cols, i % cols)
1793                } else {
1794                    (i % rows, i / rows)
1795                };
1796                LegendEntry {
1797                    break_idx: i,
1798                    row,
1799                    x_offset: col as f64 * col_pitch,
1800                    label_override: None,
1801                }
1802            })
1803            .collect();
1804        return Some(LegendPlan {
1805            rows,
1806            entries,
1807            overflow: None,
1808            cols,
1809            col_pitch,
1810        });
1811    }
1812
1813    // Auto mode: D7 scopes auto-wrap to horizontal legends only.
1814    if !is_horizontal {
1815        return None;
1816    }
1817
1818    let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1819    let total_width: f64 = pitches.iter().sum();
1820    if total_width <= avail_width.max(0.0) {
1821        return None; // fits on one line — byte-identical to 0.21.
1822    }
1823
1824    let item_height = theme.legend_key_height.max(1.0);
1825    let row_limit = guide
1826        .max_rows
1827        .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1828        .max(1);
1829
1830    let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1831    let mut row = 0usize;
1832    let mut cur_x = 0.0_f64;
1833    for (i, &p) in pitches.iter().enumerate() {
1834        if cur_x > 0.0 && cur_x + p > avail_width {
1835            if row + 1 >= row_limit {
1836                break; // out of rows — the rest overflow into "+N more".
1837            }
1838            row += 1;
1839            cur_x = 0.0;
1840        }
1841        entries.push(LegendEntry {
1842            break_idx: i,
1843            row,
1844            x_offset: cur_x,
1845            label_override: None,
1846        });
1847        cur_x += p;
1848    }
1849
1850    if entries.len() >= n {
1851        return Some(LegendPlan {
1852            rows: row + 1,
1853            entries,
1854            overflow: None,
1855            cols: 1,
1856            col_pitch: 0.0,
1857        });
1858    }
1859
1860    // Overflow: make room for "+{k} more" on the last row, dropping
1861    // entries from its tail until it fits (or the row is empty).
1862    let mut dropped = n - entries.len();
1863    loop {
1864        let text = format!("+{dropped} more");
1865        let more_pitch = legend_pitch(&text, theme);
1866        let row_count = entries.iter().filter(|e| e.row == row).count();
1867        let last_x = entries
1868            .iter()
1869            .filter(|e| e.row == row)
1870            .map(|e| e.x_offset + pitches[e.break_idx])
1871            .fold(0.0_f64, f64::max);
1872        if last_x + more_pitch <= avail_width || row_count == 0 {
1873            return Some(LegendPlan {
1874                rows: row + 1,
1875                entries,
1876                overflow: Some(LegendOverflow {
1877                    dropped,
1878                    row,
1879                    x_offset: last_x,
1880                    text,
1881                }),
1882                cols: 1,
1883                col_pitch: 0.0,
1884            });
1885        }
1886        entries.pop();
1887        dropped += 1;
1888    }
1889}
1890
1891/// `Color`/`Fill` are the only aesthetics whose *continuous* legend draws
1892/// as an actual colourbar (`draw_continuous_legend_at`, fixed width). Every
1893/// other continuous legend (`Size`, `Linewidth`, `Alpha`, …) draws sampled
1894/// breaks the same way a discrete guide's keys do
1895/// (`draw_discrete_legend_at` / `draw_discrete_legend_grid`), so it needs
1896/// the same B3-2 wrap/grid planning a discrete guide gets rather than a
1897/// fixed-width reservation — sipemu/ggplot-rs#545: previously this wasn't
1898/// planned *at all*, so a `Size`/`Linewidth` legend with enough keys to
1899/// overflow a narrow `Top`/`Bottom` row just drew past the panel/canvas
1900/// edge with no wrap, shrink, or "+N more" to catch it.
1901pub(crate) fn is_colorbar_aesthetic(aes: &Aesthetic) -> bool {
1902    matches!(aes, Aesthetic::Color | Aesthetic::Fill)
1903}
1904
1905/// How many of a plot's legend scales get B3-2's wrap/grid planning — every
1906/// discrete guide, plus a continuous guide that isn't a colourbar (#545:
1907/// its sampled breaks wrap/shrink exactly like a discrete guide's keys do)
1908/// — used to split the available horizontal room among them when several
1909/// sit side by side (sipemu/ggplot-rs#155).
1910fn wrap_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1911    legend_scales
1912        .iter()
1913        .filter(|aes| {
1914            let is_discrete = scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false);
1915            is_discrete || !is_colorbar_aesthetic(aes)
1916        })
1917        .count()
1918        .max(1)
1919}
1920
1921/// The fixed horizontal width one colourbar guide reserves in a
1922/// horizontal row — the same per-guide step `draw_legend_planned` already
1923/// advances `offset_x` by once it has drawn one (see its `is_horizontal`
1924/// arm for a colourbar).
1925fn colorbar_guide_width(theme: &Theme, bar: &GuideColourbar) -> f64 {
1926    // #211: only used where a colourbar guide shares a *horizontal* row
1927    // with another one — its footprint there is the horizontal bar's
1928    // own length (ticks/labels sit below it now, not to its side), the
1929    // same width `draw_legend_planned` steps `offset_x` by.
1930    let (bar_len, _) = continuous_bar_dims(theme, true, bar);
1931    bar_len + theme.legend_spacing * 2.0
1932}
1933
1934/// The width the plot's true colourbars (continuous `Color`/`Fill`
1935/// scales) reserve in a horizontal row: each its own
1936/// [`colorbar_guide_width`], with its own `guide_colourbar` size (#409).
1937/// (#170 / review #167 finding 7: a bottom legend mixing a colourbar with
1938/// a discrete guide ignored the colourbar's share, so the discrete guide's
1939/// auto-wrap/grid was planned against the *whole* row and could still run
1940/// off the right edge.)
1941fn colorbar_guides_width(
1942    scales: &ScaleSet,
1943    legend_scales: &[Aesthetic],
1944    theme: &Theme,
1945    guide: &GuideLegend,
1946) -> f64 {
1947    legend_scales
1948        .iter()
1949        .filter(|aes| {
1950            is_colorbar_aesthetic(aes) && !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false)
1951        })
1952        .map(|aes| colorbar_guide_width(theme, &guide.colourbar_for(aes)))
1953        .sum()
1954}
1955
1956/// Plan every wrap-eligible legend scale's B3-2 layout (explicit
1957/// `nrow`/`ncol` grid, or auto-wrap) independently (sipemu/ggplot-rs#155):
1958/// previously this only ran for the plot's single discrete guide, leaving
1959/// a second simultaneous discrete guide (e.g. both `colour` and `shape`
1960/// mapped to discrete variables) always on the unwrapped 0.21 path. A
1961/// guide is wrap-eligible when it's discrete, or continuous but not a
1962/// colourbar (#545: `Size`/`Linewidth`/… sampled breaks wrap/shrink the
1963/// same way a discrete guide's keys do — only a true continuous
1964/// `Color`/`Fill` colourbar is exempt). When several wrap-eligible guides
1965/// sit side by side (`is_horizontal`), the available width is split evenly
1966/// among them first, so each one wraps against its own share rather than
1967/// the whole row (which would let it claim more room than its neighbours
1968/// leave). Guides that stack vertically instead each get the full
1969/// `avail_width`, since they don't compete for it. Returns one entry per
1970/// wrap-eligible guide that needs anything beyond the single-row/single-
1971/// column path — a guide absent from the map is byte-identical to
1972/// 0.21/0.22.
1973#[allow(clippy::too_many_arguments)]
1974pub(crate) fn plan_all_legends(
1975    scales: &ScaleSet,
1976    guide: &GuideLegend,
1977    theme: &Theme,
1978    suppressed: &std::collections::HashSet<Aesthetic>,
1979    avail_width: f64,
1980    avail_height: f64,
1981    is_horizontal: bool,
1982    warnings: &mut Vec<String>,
1983) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1984    let legend_scales = legend_scales_for(scales, suppressed);
1985    let n_wrap = wrap_guide_count(scales, &legend_scales);
1986    let per_guide_width = if is_horizontal {
1987        // #170: leave the wrap-planned guides only the width left over
1988        // after every colourbar guide's own fixed share (a continuous
1989        // Color/Fill legend sitting in the same row) — previously
1990        // `avail_width` was split among the discrete guides alone, so a
1991        // colourbar plus a discrete guide could together still overflow
1992        // the row. #545: a continuous, non-colourbar guide (e.g. `Size`/
1993        // `Linewidth`) is now one of the guides this width is split among
1994        // too, rather than drawing unconstrained.
1995        let colorbar_width = colorbar_guides_width(scales, &legend_scales, theme, guide);
1996        ((avail_width - colorbar_width) / n_wrap as f64).max(0.0)
1997    } else {
1998        avail_width
1999    };
2000
2001    let mut plans = std::collections::HashMap::new();
2002    for aes in &legend_scales {
2003        let Some(scale) = scales.get(aes) else {
2004            continue;
2005        };
2006        // #545: plan a continuous, non-colourbar guide (`Size`/
2007        // `Linewidth`/… sampled breaks) exactly like a discrete one —
2008        // only a true colourbar (continuous `Color`/`Fill`) is exempt
2009        // from B3-2's wrap/grid planning.
2010        if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
2011            continue;
2012        }
2013        // `guide_bins()` (#409) lays its keys out on its own.
2014        if guide.bins_for(aes, scale).is_some() {
2015            continue;
2016        }
2017        let breaks = scale.breaks();
2018        let validated_guide = guide.validated(breaks.len(), warnings);
2019        // #271: size against `breaks_for_layout()`, not `breaks` itself —
2020        // identical for every scale except `ScaleFillBinned` under
2021        // `count_reserve`, where it reserves width for the widest count
2022        // digit width so the resulting row count doesn't depend on which
2023        // frame's counts happen to be measured.
2024        let layout_breaks = scale.breaks_for_layout();
2025        if let Some(plan) = plan_legend(
2026            &layout_breaks,
2027            &validated_guide,
2028            theme,
2029            per_guide_width,
2030            avail_height,
2031            is_horizontal,
2032        ) {
2033            plans.insert(aes.clone(), plan);
2034        }
2035    }
2036    plans
2037}
2038
2039/// Draw all legends for the plot, honouring each discrete guide's B3-2
2040/// `plan_legend` layout (explicit `nrow`/`ncol`, or an overflowing
2041/// horizontal legend's auto-wrap) when it has one — several simultaneous
2042/// discrete guides each get their own independent plan
2043/// (sipemu/ggplot-rs#155). A guide missing from `legend_plans` falls back
2044/// to the unchanged [`draw_legend`] path — same calls, same order — which
2045/// keeps this byte-identical to 0.21/0.22 for every plot nothing in
2046/// `legend_plans` touches. Used by `renderer.rs` (single and faceted) and
2047/// `compose::capture_legend` (PlotGrid-collected legends).
2048/// An inside legend's own bounding box ([`LegendPosition::Inside`] only —
2049/// every other position reserves a dedicated band outside `plot_area`, so
2050/// it can never overlap panel-corner furniture living inside it), without
2051/// drawing anything (#273). `renderer.rs` calls this *before* any geom
2052/// draws, so a corner-furniture geom drawn first (`annotation_scale`/
2053/// `annotation_attribution`/`bivariate_legend`) can still stack clear of
2054/// where the legend — drawn last, see `draw_legend_planned` — will land.
2055/// Mirrors that function's own prefix (`legend_scales_for` →
2056/// `group_mergeable_legends` → `estimate_legend_box` → `legend_position`,
2057/// with `legend_extra_rows` fixed at `0` since `Inside` never reads it)
2058/// so this never disagrees with where the legend is actually drawn.
2059pub(crate) fn inside_legend_box(
2060    scales: &ScaleSet,
2061    theme: &Theme,
2062    plot_area: &Rect,
2063    canvas: &Rect,
2064    guide: &GuideLegend,
2065    suppressed: &std::collections::HashSet<Aesthetic>,
2066    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2067) -> Option<Rect> {
2068    if !matches!(theme.legend_position, LegendPosition::Inside(..)) {
2069        return None;
2070    }
2071    let legend_scales = legend_scales_for(scales, suppressed);
2072    if legend_scales.is_empty() {
2073        return None;
2074    }
2075    let groups = group_mergeable_legends(scales, &legend_scales, guide);
2076    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2077    let legend_size_hint =
2078        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
2079    let lift = inside_title_lift(scales, &representatives, theme, guide);
2080    let (legend_x, legend_y, _) =
2081        legend_position(theme, plot_area, 0, legend_size_hint, lift, canvas);
2082    // The visible box, title included (#377).
2083    Some(Rect {
2084        x: legend_x,
2085        y: legend_y - lift,
2086        width: legend_size_hint.0,
2087        height: legend_size_hint.1 + lift,
2088    })
2089}
2090
2091/// The sides (right, left, top, bottom) a `guide_position` (#409) moved a
2092/// drawn legend to, away from `main` — each needs its own band.
2093pub(crate) fn extra_legend_sides(
2094    scales: &ScaleSet,
2095    suppressed: &std::collections::HashSet<Aesthetic>,
2096    guide: &GuideLegend,
2097    main: &LegendPosition,
2098) -> [bool; 4] {
2099    let mut sides = [false; 4];
2100    if guide.positions.is_empty() || matches!(main, LegendPosition::None) {
2101        return sides;
2102    }
2103    for aes in legend_scales_for(scales, suppressed) {
2104        match guide.position_for(&aes, main) {
2105            p if p == main => {}
2106            LegendPosition::Right => sides[0] = true,
2107            LegendPosition::Left => sides[1] = true,
2108            LegendPosition::Top => sides[2] = true,
2109            LegendPosition::Bottom => sides[3] = true,
2110            LegendPosition::None | LegendPosition::Inside(..) => {}
2111        }
2112    }
2113    sides
2114}
2115
2116/// Draw every legend: those at the theme's `legend_position`, then each
2117/// group a `guide_position` (#409) moved to another side, drawn as if the
2118/// theme put the legend there.
2119#[allow(clippy::too_many_arguments)]
2120pub(crate) fn draw_legend_planned(
2121    scales: &ScaleSet,
2122    theme: &Theme,
2123    plot_area: &Rect,
2124    legend_avail_width: f64,
2125    backend: &mut dyn DrawBackend,
2126    guide: &GuideLegend,
2127    suppressed: &std::collections::HashSet<Aesthetic>,
2128    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2129) -> Result<(), RenderError> {
2130    let main = &theme.legend_position;
2131    let drawn = legend_scales_for(scales, suppressed);
2132    let mut others: Vec<LegendPosition> = Vec::new();
2133    if !matches!(main, LegendPosition::None) {
2134        for aes in &drawn {
2135            let p = guide.position_for(aes, main);
2136            if p != main && !matches!(p, LegendPosition::None) && !others.contains(p) {
2137                others.push(p.clone());
2138            }
2139        }
2140    }
2141    if others.is_empty() {
2142        return draw_legend_side(
2143            scales,
2144            theme,
2145            plot_area,
2146            legend_avail_width,
2147            backend,
2148            guide,
2149            suppressed,
2150            legend_plans,
2151        );
2152    }
2153    // Everything not at `at` joins the suppressed set for that pass.
2154    let without = |at: &LegendPosition| -> std::collections::HashSet<Aesthetic> {
2155        let mut s = suppressed.clone();
2156        s.extend(
2157            drawn
2158                .iter()
2159                .filter(|a| guide.position_for(a, main) != at)
2160                .cloned(),
2161        );
2162        s
2163    };
2164    draw_legend_side(
2165        scales,
2166        theme,
2167        plot_area,
2168        legend_avail_width,
2169        backend,
2170        guide,
2171        &without(main),
2172        legend_plans,
2173    )?;
2174    let moved_guide = GuideLegend {
2175        custom: Vec::new(),
2176        ..guide.clone()
2177    };
2178    for at in &others {
2179        let side_theme = Theme {
2180            legend_position: at.clone(),
2181            ..theme.clone()
2182        };
2183        draw_legend_side(
2184            scales,
2185            &side_theme,
2186            plot_area,
2187            legend_avail_width,
2188            backend,
2189            &moved_guide,
2190            &without(at),
2191            legend_plans,
2192        )?;
2193    }
2194    Ok(())
2195}
2196
2197/// One side's legends (see [`draw_legend_planned`]).
2198#[allow(clippy::too_many_arguments)]
2199fn draw_legend_side(
2200    scales: &ScaleSet,
2201    theme: &Theme,
2202    plot_area: &Rect,
2203    // Review #167 finding 1: the width share each discrete guide was
2204    // *planned* against (the same `avail_width` passed to
2205    // `plan_all_legends`), not `plot_area.width`. For a single plot these
2206    // are the same value in practice, but a PlotGrid's collected legend
2207    // (`compose::capture_legend`) measures itself against a fake
2208    // zero-width panel so its origin lands at (0, 0) — stepping by
2209    // `plot_area.width / n` there always advanced by zero, so the second
2210    // discrete guide (e.g. `shape` after `colour`) was drawn directly on
2211    // top of the first instead of beside it.
2212    legend_avail_width: f64,
2213    backend: &mut dyn DrawBackend,
2214    guide: &GuideLegend,
2215    suppressed: &std::collections::HashSet<Aesthetic>,
2216    // Review #158 finding 2: each discrete guide's plan, computed exactly
2217    // once in `layout_built` (`BuiltPlot::legend_plans`) — drawn verbatim
2218    // here rather than recomputed against this (possibly shorter, post-
2219    // row-reservation) `plot_area`, so the drawn "+N more" always matches
2220    // the warning that plan already produced.
2221    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2222) -> Result<(), RenderError> {
2223    if matches!(theme.legend_position, LegendPosition::None) {
2224        return Ok(());
2225    }
2226
2227    let legend_scales = legend_scales_for(scales, suppressed);
2228    if legend_scales.is_empty() && guide.custom.is_empty() {
2229        return Ok(());
2230    }
2231    // #410: key glyphs are lines / points outside the panel.
2232    backend.set_clipping(false);
2233    let drawn = draw_legend_planned_inner(
2234        scales,
2235        theme,
2236        plot_area,
2237        legend_avail_width,
2238        backend,
2239        guide,
2240        legend_plans,
2241        legend_scales,
2242    );
2243    backend.set_clipping(true);
2244    drawn
2245}
2246
2247#[allow(clippy::too_many_arguments)]
2248fn draw_legend_planned_inner(
2249    scales: &ScaleSet,
2250    theme: &Theme,
2251    plot_area: &Rect,
2252    legend_avail_width: f64,
2253    backend: &mut dyn DrawBackend,
2254    guide: &GuideLegend,
2255    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2256    legend_scales: Vec<Aesthetic>,
2257) -> Result<(), RenderError> {
2258    // #239: group aesthetics whose legend would be visually identical
2259    // (same title, breaks, discrete/continuous-ness) into one combined
2260    // guide, rather than drawing one per aesthetic. Everything below that
2261    // used to iterate `legend_scales` one aesthetic at a time now
2262    // iterates `groups` instead; a group's first member (`representatives`)
2263    // stands in for the whole group wherever only one scale's own
2264    // breaks/title/discreteness is needed (sizing, the B3-2 wrap plan
2265    // lookup, …) — `group_mergeable_legends` only ever groups scales
2266    // whose `breaks()` already match, so any member would do.
2267    let groups = group_mergeable_legends(scales, &legend_scales, guide);
2268    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2269
2270    // #233: `legend_position`'s `Top` arm needs the same wrapped-row
2271    // count `layout_built` fed `PlotLayout::compute_with_extras` to
2272    // produce this `plot_area` in the first place — recomputed here from
2273    // `legend_plans` (the exact plans that count came from) the same way
2274    // `layout_built` did, so it can never disagree with the band
2275    // `plot_area.y` was actually reserved against. `is_horizontal`'s
2276    // auto value only depends on the position itself (`Top`/`Bottom`),
2277    // overridable by `legend_direction` same as below — computed first
2278    // since `legend_position` needs it to pick `max` (several guides
2279    // sharing a row) vs. `sum` (several guides stacked), the same
2280    // either/or `layout_built` used.
2281    let is_horizontal_hint = match theme.legend_direction {
2282        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
2283        None => matches!(
2284            theme.legend_position,
2285            LegendPosition::Top | LegendPosition::Bottom
2286        ),
2287    };
2288    let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
2289    // #225: `Inside`'s justification needs the legend's own estimated
2290    // footprint and the full canvas to clamp against — cheap to compute
2291    // unconditionally (ignored by every other position). Estimated from
2292    // `representatives`, not every raw aesthetic, so a merged group's
2293    // height is counted once, not once per aesthetic in it.
2294    let legend_size_hint =
2295        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
2296    let canvas = backend.total_area();
2297    let (legend_x, legend_y, mut is_horizontal) = legend_position(
2298        theme,
2299        plot_area,
2300        legend_extra_rows,
2301        legend_size_hint,
2302        inside_title_lift(scales, &representatives, theme, guide),
2303        &canvas,
2304    );
2305    if let Some(dir) = theme.legend_direction {
2306        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
2307    }
2308    let n_wrap = wrap_guide_count(scales, &representatives);
2309    // #170: match `plan_all_legends`'s share — a colourbar guide (a
2310    // continuous Color/Fill legend) sharing this row has already claimed
2311    // its own fixed width, so step the wrap-planned guides by what's
2312    // left, not the whole `legend_avail_width`. #545: a continuous,
2313    // non-colourbar guide (`Size`/`Linewidth`/…) is one of the
2314    // wrap-planned guides this is split among now, not an unconstrained
2315    // extra.
2316    let colorbar_width = colorbar_guides_width(scales, &representatives, theme, guide);
2317    let per_guide_width = ((legend_avail_width - colorbar_width) / n_wrap as f64).max(0.0);
2318
2319    let mut offset_y = legend_y;
2320    let mut offset_x = legend_x;
2321
2322    // #413: `legend.box.just` — line the guides up along the stack's cross
2323    // axis (left / centre / right of the widest when stacked, top / centre
2324    // / bottom of the tallest side by side). 0 (the default) draws nothing
2325    // differently.
2326    let just = theme.legend_box_just.clamp(0.0, 1.0);
2327    let cross: Vec<f64> = if just > 0.0 {
2328        representatives
2329            .iter()
2330            .map(|aes| {
2331                if is_horizontal {
2332                    horizontal_guide_height(scales, aes, theme, guide, legend_plans)
2333                } else {
2334                    estimate_guide_size(scales, aes, theme, guide, legend_plans)
2335                        .map_or(0.0, |(w, _)| w)
2336                }
2337            })
2338            .collect()
2339    } else {
2340        vec![0.0; representatives.len()]
2341    };
2342    let cross_max = cross.iter().copied().fold(0.0_f64, f64::max);
2343
2344    for (gi, group) in groups.iter().enumerate() {
2345        let shift = (cross_max - cross[gi]) * just;
2346        let (draw_x, draw_y) = if is_horizontal {
2347            (offset_x, offset_y + shift)
2348        } else {
2349            (offset_x + shift, offset_y)
2350        };
2351        let representative = &group[0];
2352        let rep_scale = scales.get(representative).unwrap();
2353        let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
2354            .iter()
2355            .map(|a| (a.clone(), scales.get(a).unwrap()))
2356            .collect();
2357
2358        if !rep_scale.is_discrete() && is_colorbar_aesthetic(representative) {
2359            // A true colourbar (continuous Color/Fill) draws as a fixed-
2360            // width bar — not in scope for B3-2's wrap/grid planning.
2361            // Every other continuous legend (Size/Linewidth/… sampled
2362            // breaks, #545) falls through to the same wrap/grid-aware
2363            // path a discrete guide uses, below.
2364            //
2365            // #239: a merged continuous fill+colour pair draws one
2366            // bar — `group_mergeable_legends` only merges scales
2367            // whose breaks (so, in practice, colours) already match,
2368            // so the other member(s) would draw the identical bar
2369            // again.
2370            let height = draw_continuous_legend_at(
2371                rep_scale,
2372                theme,
2373                draw_x,
2374                draw_y,
2375                backend,
2376                guide,
2377                is_horizontal,
2378            )?;
2379            if is_horizontal {
2380                // #211: the bar is now drawn along x (wide, not
2381                // narrow) — step by its own length, not the old
2382                // vertical-bar width-plus-side-label estimate.
2383                let (bar_len, _) =
2384                    continuous_bar_dims(theme, true, &guide.colourbar_for(representative));
2385                offset_x += bar_len + theme.legend_spacing * 2.0;
2386            } else {
2387                offset_y += height + theme.legend_spacing * 2.0;
2388            }
2389            continue;
2390        }
2391
2392        // A discrete guide, or a continuous non-colourbar guide (#545:
2393        // `Size`/`Linewidth`/… sampled breaks draw the same way a
2394        // discrete guide's keys do) — both get the same B3-2 wrap/grid
2395        // plan when `plan_all_legends` gave them one.
2396        match legend_plans.get(representative) {
2397            None => {
2398                if is_horizontal {
2399                    let width = draw_discrete_legend_at(
2400                        scales,
2401                        &scale_group,
2402                        theme,
2403                        draw_x,
2404                        draw_y,
2405                        backend,
2406                        guide,
2407                        true,
2408                    )?;
2409                    offset_x += width + theme.legend_spacing * 2.0;
2410                } else {
2411                    let height = draw_discrete_legend_at(
2412                        scales,
2413                        &scale_group,
2414                        theme,
2415                        draw_x,
2416                        draw_y,
2417                        backend,
2418                        guide,
2419                        false,
2420                    )?;
2421                    offset_y += height + theme.legend_spacing * 2.0;
2422                }
2423            }
2424            Some(plan) => {
2425                let height = draw_discrete_legend_grid(
2426                    scales,
2427                    &scale_group,
2428                    theme,
2429                    draw_x,
2430                    draw_y,
2431                    backend,
2432                    guide,
2433                    plan,
2434                )?;
2435                if is_horizontal {
2436                    // Each discrete guide gets an equal share of the row
2437                    // (sipemu/ggplot-rs#155) rather than the whole row,
2438                    // which only held for exactly one discrete guide.
2439                    offset_x += per_guide_width;
2440                } else {
2441                    offset_y += height + theme.legend_spacing * 2.0;
2442                }
2443            }
2444        }
2445    }
2446
2447    // #409: custom guides (`guide_custom()`), after the scales' own.
2448    let family = (!theme.legend_title.family.is_empty()).then(|| theme.legend_title.family.clone());
2449    for custom in &guide.custom {
2450        let title_offset = custom.title_offset(theme);
2451        if let Some(title) = custom.title.as_deref().filter(|t| !t.is_empty()) {
2452            backend.set_element_class(Some("gg-legend gg-legend-title"));
2453            crate::render::rich::draw(
2454                backend,
2455                theme.rich_text,
2456                title,
2457                (offset_x, offset_y),
2458                &TextStyle {
2459                    color: theme.legend_title.color,
2460                    size: theme.legend_title.size,
2461                    anchor: TextAnchor::Start,
2462                    angle: 0.0,
2463                    family: family.clone(),
2464                    face: theme.legend_title.face,
2465                    bg_colour: None,
2466                    bg_r: 0.0,
2467                },
2468            )?;
2469        }
2470        let rect = Rect {
2471            x: offset_x,
2472            y: offset_y + title_offset,
2473            width: custom.width,
2474            height: custom.height,
2475        };
2476        backend.set_element_class(Some("gg-legend gg-legend-custom"));
2477        (custom.draw)(backend, &rect)?;
2478        if is_horizontal {
2479            offset_x += custom.width + theme.legend_spacing * 2.0;
2480        } else {
2481            offset_y += title_offset + custom.height + theme.legend_spacing * 2.0;
2482        }
2483    }
2484    Ok(())
2485}
2486
2487/// Draw a discrete legend using a [`LegendPlan`] (explicit grid or
2488/// auto-wrap). Returns the total height used (including any "+N more"
2489/// row).
2490#[allow(clippy::too_many_arguments)]
2491/// Wrapped-grid counterpart of [`draw_discrete_legend_at`] — same `group`
2492/// merging (#239).
2493fn draw_discrete_legend_grid(
2494    scales: &ScaleSet,
2495    group: &[(Aesthetic, &dyn crate::scale::Scale)],
2496    theme: &Theme,
2497    legend_x: f64,
2498    legend_y: f64,
2499    backend: &mut dyn DrawBackend,
2500    guide: &GuideLegend,
2501    plan: &LegendPlan,
2502) -> Result<f64, RenderError> {
2503    let (_, scale) = &group[0];
2504    let mut breaks = scale.breaks();
2505    if breaks.is_empty() {
2506        return Ok(0.0);
2507    }
2508    if guide.is_reversed(&group[0].0) {
2509        breaks.reverse();
2510    }
2511
2512    // #236: same key-box growth as `draw_discrete_legend_at`.
2513    let diameter = group
2514        .iter()
2515        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
2516        .fold(0.0_f64, f64::max);
2517    let swatch_size = theme.legend_key_width.max(diameter);
2518    let item_height = theme.legend_key_height.max(swatch_size);
2519    // #413: `legend.key.spacing.y` between rows; `row_pitch` steps them.
2520    let gap_y = theme.legend_key_gap_y();
2521    let row_pitch = item_height + gap_y;
2522
2523    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
2524    let legend_family = if theme.legend_title.family.is_empty() {
2525        None
2526    } else {
2527        Some(theme.legend_title.family.clone())
2528    };
2529    let title_offset = if !title.is_empty() {
2530        backend.set_element_class(Some("gg-legend gg-legend-title"));
2531        crate::render::rich::draw(
2532            backend,
2533            theme.rich_text,
2534            title,
2535            (legend_x, legend_y),
2536            &TextStyle {
2537                color: theme.legend_title.color,
2538                size: theme.legend_title.size,
2539                anchor: TextAnchor::Start,
2540                angle: 0.0,
2541                family: legend_family,
2542                face: theme.legend_title.face,
2543                bg_colour: None,
2544                bg_r: 0.0,
2545            },
2546        )?;
2547        theme.legend_title.size + 4.0
2548    } else {
2549        0.0
2550    };
2551    let items_y = legend_y + title_offset;
2552
2553    for entry in &plan.entries {
2554        let (pos, label) = &breaks[entry.break_idx];
2555        let x = legend_x + entry.x_offset;
2556        let y = items_y + entry.row as f64 * row_pitch;
2557        let center_x = x + swatch_size / 2.0;
2558        let center_y = y + swatch_size / 2.0;
2559
2560        if theme.legend_key.visible {
2561            if let Some(fill) = theme.legend_key.fill {
2562                backend.set_element_class(Some("gg-legend gg-legend-key"));
2563                backend.draw_rect(
2564                    (x, y),
2565                    (x + swatch_size, y + swatch_size),
2566                    &RectStyle {
2567                        fill: Some(fill),
2568                        stroke: theme.legend_key.color,
2569                        stroke_width: theme.legend_key.width,
2570                        alpha: 1.0,
2571                        clip: false,
2572                        corner_radius: 0.0,
2573                    },
2574                )?;
2575            }
2576        }
2577
2578        backend.set_element_class(Some("gg-legend gg-legend-key"));
2579        draw_group_keys(
2580            backend,
2581            scales,
2582            group,
2583            theme,
2584            guide,
2585            label,
2586            *pos,
2587            (x, y, center_x, center_y),
2588            swatch_size,
2589        )?;
2590
2591        let label_family = if theme.legend_text.family.is_empty() {
2592            None
2593        } else {
2594            Some(theme.legend_text.family.clone())
2595        };
2596        // #159: a label truncated to fit a vertical multi-column grid
2597        // still shows its full text via a `<title>` tooltip (backends
2598        // without tooltip support just render the truncated text).
2599        let display = entry.label_override.as_deref().unwrap_or(label.as_str());
2600        backend.set_element_class(Some("gg-legend gg-legend-text"));
2601        backend.draw_text_with_title(
2602            display,
2603            (x + swatch_size + theme.legend_spacing, center_y),
2604            &TextStyle {
2605                color: theme.legend_text.color,
2606                size: theme.legend_text.size,
2607                anchor: TextAnchor::Start,
2608                angle: 0.0,
2609                family: label_family,
2610                face: theme.legend_text.face,
2611                bg_colour: None,
2612                bg_r: 0.0,
2613            },
2614            entry.label_override.as_deref().map(|_| label.as_str()),
2615        )?;
2616    }
2617
2618    // The overflow marker is text only — not a mark, no swatch.
2619    if let Some(ov) = &plan.overflow {
2620        let x = legend_x + ov.x_offset;
2621        let y = items_y + ov.row as f64 * row_pitch + swatch_size / 2.0;
2622        let label_family = if theme.legend_text.family.is_empty() {
2623            None
2624        } else {
2625            Some(theme.legend_text.family.clone())
2626        };
2627        backend.set_element_class(Some("gg-legend gg-legend-text"));
2628        crate::render::rich::draw(
2629            backend,
2630            theme.rich_text,
2631            &ov.text,
2632            (x, y),
2633            &TextStyle {
2634                color: theme.legend_text.color,
2635                size: theme.legend_text.size,
2636                anchor: TextAnchor::Start,
2637                angle: 0.0,
2638                family: label_family,
2639                face: theme.legend_text.face,
2640                bg_colour: None,
2641                bg_r: 0.0,
2642            },
2643        )?;
2644    }
2645
2646    Ok(title_offset + plan.rows as f64 * row_pitch - gap_y)
2647}
2648
2649#[cfg(test)]
2650mod group_mergeable_legends_tests {
2651    use super::*;
2652    use crate::scale::color::RGBAColor;
2653    use crate::scale::manual::ScaleManual;
2654
2655    #[test]
2656    fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
2657        // #239: in the normal flow, `group_mergeable_legends` is only ever
2658        // handed `legend_scales_for`'s output, which already filters to
2659        // aesthetics present in `scales` — so the `let-else`'s `continue`
2660        // branch (an aesthetic that slipped through with no attached
2661        // scale) is otherwise unreachable from `draw_legend_planned`.
2662        // Exercise it directly: `Fill` is listed but has no scale.
2663        let mut scales = ScaleSet::new();
2664        scales.add(Box::new(ScaleManual::new(
2665            Aesthetic::Color,
2666            vec![("a", RGBAColor::new(200, 0, 0))],
2667        )));
2668        let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
2669        let guide = GuideLegend::default();
2670        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2671        assert_eq!(groups, vec![vec![Aesthetic::Color]]);
2672    }
2673}
2674
2675/// #211: direct unit coverage for the two private helpers
2676/// `draw_continuous_legend_at`'s vertical/horizontal split relies on —
2677/// exercised indirectly by every colourbar-drawing integration test
2678/// already, but called here directly too so their own branches/math
2679/// are pinned regardless of how the compiler happens to inline them.
2680#[cfg(test)]
2681mod continuous_bar_dims_tests {
2682    use super::*;
2683
2684    #[test]
2685    fn dims_swap_between_vertical_and_horizontal() {
2686        let theme = Theme::default();
2687        let (w, h) = continuous_bar_dims(&theme, false, &GuideColourbar::default());
2688        assert_eq!(w, theme.legend_key_width);
2689        assert_eq!(h, theme.legend_key_height * 8.0);
2690
2691        let (w, h) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
2692        assert_eq!(w, theme.legend_key_height * 8.0);
2693        assert_eq!(h, theme.legend_key_width);
2694    }
2695
2696    #[test]
2697    fn horizontal_bar_height_adds_a_title_line_only_when_there_is_a_title() {
2698        let theme = Theme::default();
2699        let without_title =
2700            continuous_horizontal_bar_height(&theme, false, &GuideColourbar::default());
2701        let with_title = continuous_horizontal_bar_height(&theme, true, &GuideColourbar::default());
2702        assert!(with_title > without_title);
2703        assert!(((with_title - without_title) - (theme.legend_title.size + 4.0)).abs() < 1e-9);
2704
2705        // Matches `continuous_bar_dims`'s own horizontal thickness plus
2706        // the fixed tick/spacing/label overhead — not just a positive
2707        // number, the exact formula `draw_continuous_legend_at` relies
2708        // on to never disagree with what `plot.rs` reserves for it.
2709        let (_, thickness) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
2710        let tick_len = 3.0;
2711        let label_line = theme.legend_text.size + 4.0;
2712        assert!(
2713            (without_title - (thickness + tick_len + theme.legend_spacing + label_line)).abs()
2714                < 1e-9
2715        );
2716    }
2717}
2718
2719/// #273: `inside_legend_box` — the pre-draw measurement `renderer.rs` uses
2720/// to claim an inside legend's box before any corner-furniture geom draws.
2721#[cfg(test)]
2722mod inside_legend_box_tests {
2723    use super::*;
2724    use crate::scale::color::RGBAColor;
2725    use crate::scale::manual::ScaleManual;
2726
2727    fn scales_with_a_legend() -> ScaleSet {
2728        let mut scales = ScaleSet::new();
2729        scales.add(Box::new(ScaleManual::new(
2730            Aesthetic::Color,
2731            vec![
2732                ("a", RGBAColor::new(200, 0, 0)),
2733                ("b", RGBAColor::new(0, 0, 200)),
2734            ],
2735        )));
2736        scales
2737    }
2738
2739    fn areas() -> (Rect, Rect) {
2740        let plot_area = Rect {
2741            x: 10.0,
2742            y: 10.0,
2743            width: 200.0,
2744            height: 150.0,
2745        };
2746        let canvas = Rect {
2747            x: 0.0,
2748            y: 0.0,
2749            width: 220.0,
2750            height: 170.0,
2751        };
2752        (plot_area, canvas)
2753    }
2754
2755    #[test]
2756    fn non_inside_positions_claim_nothing() {
2757        let scales = scales_with_a_legend();
2758        let (plot_area, canvas) = areas();
2759        let guide = GuideLegend::default();
2760        let suppressed = std::collections::HashSet::new();
2761        let legend_plans = std::collections::HashMap::new();
2762        for pos in [
2763            LegendPosition::Right,
2764            LegendPosition::Left,
2765            LegendPosition::Top,
2766            LegendPosition::Bottom,
2767            LegendPosition::None,
2768        ] {
2769            let theme = Theme {
2770                legend_position: pos,
2771                ..Theme::default()
2772            };
2773            assert!(inside_legend_box(
2774                &scales,
2775                &theme,
2776                &plot_area,
2777                &canvas,
2778                &guide,
2779                &suppressed,
2780                &legend_plans
2781            )
2782            .is_none());
2783        }
2784    }
2785
2786    #[test]
2787    fn no_legend_scales_claims_nothing_even_when_inside() {
2788        let scales = ScaleSet::new(); // nothing mapped to a legend aesthetic
2789        let (plot_area, canvas) = areas();
2790        let theme = Theme {
2791            legend_position: LegendPosition::Inside(0.85, 0.85),
2792            ..Theme::default()
2793        };
2794        let guide = GuideLegend::default();
2795        let suppressed = std::collections::HashSet::new();
2796        let legend_plans = std::collections::HashMap::new();
2797        assert!(inside_legend_box(
2798            &scales,
2799            &theme,
2800            &plot_area,
2801            &canvas,
2802            &guide,
2803            &suppressed,
2804            &legend_plans
2805        )
2806        .is_none());
2807    }
2808
2809    #[test]
2810    fn inside_position_with_a_legend_claims_a_box_within_the_panel() {
2811        let scales = scales_with_a_legend();
2812        let (plot_area, canvas) = areas();
2813        let theme = Theme {
2814            legend_position: LegendPosition::Inside(0.85, 0.85),
2815            ..Theme::default()
2816        };
2817        let guide = GuideLegend::default();
2818        let suppressed = std::collections::HashSet::new();
2819        let legend_plans = std::collections::HashMap::new();
2820        let rect = inside_legend_box(
2821            &scales,
2822            &theme,
2823            &plot_area,
2824            &canvas,
2825            &guide,
2826            &suppressed,
2827            &legend_plans,
2828        )
2829        .expect("an Inside legend with a mapped scale claims a box");
2830        assert!(rect.width > 0.0 && rect.height > 0.0);
2831        assert!(rect.x >= canvas.x - 1e-6 && rect.x + rect.width <= canvas.x + canvas.width + 1e-6);
2832        assert!(
2833            rect.y >= canvas.y - 1e-6 && rect.y + rect.height <= canvas.y + canvas.height + 1e-6
2834        );
2835    }
2836
2837    #[test]
2838    fn matches_the_position_draw_legend_planned_itself_uses() {
2839        // Mirrors `draw_legend_planned`'s own prefix (`legend_scales_for`
2840        // → `group_mergeable_legends` → `estimate_legend_box` →
2841        // `legend_position`) — this must agree with it, or a geom drawn
2842        // before the legend could stack clear of the wrong spot.
2843        let scales = scales_with_a_legend();
2844        let (plot_area, canvas) = areas();
2845        let theme = Theme {
2846            legend_position: LegendPosition::Inside(0.85, 0.85),
2847            ..Theme::default()
2848        };
2849        let guide = GuideLegend::default();
2850        let suppressed = std::collections::HashSet::new();
2851        let legend_plans = std::collections::HashMap::new();
2852
2853        let rect = inside_legend_box(
2854            &scales,
2855            &theme,
2856            &plot_area,
2857            &canvas,
2858            &guide,
2859            &suppressed,
2860            &legend_plans,
2861        )
2862        .unwrap();
2863
2864        let legend_scales = legend_scales_for(&scales, &suppressed);
2865        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2866        let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2867        let size_hint =
2868            estimate_legend_box(&scales, &representatives, &theme, &guide, &legend_plans);
2869        let lift = inside_title_lift(&scales, &representatives, &theme, &guide);
2870        let (x, y, _) = legend_position(&theme, &plot_area, 0, size_hint, lift, &canvas);
2871
2872        // The visible box: the title's upper half above the origin (#377).
2873        assert_eq!(
2874            (rect.x, rect.y, rect.width, rect.height),
2875            (x, y - lift, size_hint.0, size_hint.1 + lift)
2876        );
2877    }
2878}