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        // #473: unmapped fill / alpha come from the layer's own constants
1647        // (a translucent ribbon's key is translucent, in the ribbon's fill).
1648        let mut props = KeyProps {
1649            fill: k.fill.map(|(f, _)| f),
1650            alpha: k.fill.map(|(_, a)| a),
1651            ..KeyProps::default()
1652        };
1653        for (aes, scale) in group {
1654            if !k.aesthetics.contains(aes) {
1655                continue;
1656            }
1657            match aes {
1658                Aesthetic::Color => props.color = scales.map_color(aes, &value),
1659                Aesthetic::Fill => props.fill = scales.map_color(aes, &value),
1660                Aesthetic::Shape => props.shape = scales.map_shape(&value),
1661                Aesthetic::Linetype => props.linetype = scales.map_linetype(&value),
1662                Aesthetic::Size => {
1663                    props.size = scale
1664                        .map_fraction_to_size(pos)
1665                        .or_else(|| scales.map_size(&value))
1666                }
1667                Aesthetic::Alpha => {
1668                    props.alpha = scale
1669                        .map_fraction_to_alpha(pos)
1670                        .or_else(|| scales.map_alpha(&value))
1671                }
1672                Aesthetic::Linewidth => props.linewidth = scale.map_fraction_to_size(pos),
1673                _ => {}
1674            }
1675        }
1676        // #409: `override.aes` for any of this legend's aesthetics wins.
1677        for (aes, _) in group {
1678            if let Some((_, o)) = guide.overrides.iter().find(|(a, _)| a == aes) {
1679                props.color = o.color.or(props.color);
1680                props.fill = o.fill.or(props.fill);
1681                props.shape = o.shape.or(props.shape);
1682                props.linetype = o.linetype.clone().or(props.linetype);
1683                props.size = o.size.or(props.size);
1684                props.alpha = o.alpha.or(props.alpha);
1685                props.linewidth = o.linewidth.or(props.linewidth);
1686            }
1687        }
1688        draw_key(backend, glyph, &props, ink, (x, y, swatch_size))?;
1689    }
1690    Ok(())
1691}
1692
1693// ─── B3-2: nrow/ncol/byrow, auto-wrap, "+N more" ───────────────────────────
1694
1695/// One legend key's grid placement, in the planner's own pixel space
1696/// (relative to the legend's own origin — `row` is 0-indexed top row,
1697/// `x_offset` is the horizontal pitch from the first column).
1698#[derive(Clone, Debug)]
1699pub(crate) struct LegendEntry {
1700    pub break_idx: usize,
1701    pub row: usize,
1702    pub x_offset: f64,
1703    /// A truncated-with-ellipsis display label (#159), set only when a
1704    /// vertical multi-column grid still doesn't fit the canvas after
1705    /// reducing its column count as far as it can go: the full label is
1706    /// still available (e.g. for a `<title>` tooltip) via the scale's own
1707    /// `breaks()[break_idx]`. `None` draws the break's label unchanged —
1708    /// the common, byte-identical case.
1709    pub label_override: Option<String>,
1710}
1711
1712/// A trailing "+{k} more" marker when the legend doesn't have room for
1713/// every key (D7). Not a mark (`draw_text` only — no swatch, no
1714/// `data-geom`/class).
1715#[derive(Clone, Debug)]
1716pub(crate) struct LegendOverflow {
1717    pub dropped: usize,
1718    pub row: usize,
1719    pub x_offset: f64,
1720    pub text: String,
1721}
1722
1723/// A non-default legend layout (B3-2): an explicit `nrow`/`ncol` grid, or
1724/// an auto-wrapped horizontal legend. `None` from [`plan_legend`] means
1725/// "nothing changes" — the caller keeps the 0.21 single-row/single-column
1726/// path verbatim, so a plot that doesn't need this is byte-identical.
1727#[derive(Clone, Debug)]
1728pub(crate) struct LegendPlan {
1729    pub rows: usize,
1730    pub entries: Vec<LegendEntry>,
1731    pub overflow: Option<LegendOverflow>,
1732    /// Columns in an explicit grid (always 1 for the horizontal auto-wrap
1733    /// path, which never adds columns). Review #158 finding 3: a vertical
1734    /// legend with `ncol > 1` needs `(cols − 1) · col_pitch` of extra
1735    /// width reserved, the same way extra rows are reserved for height.
1736    pub cols: usize,
1737    /// The per-column horizontal pitch an explicit grid uses (0 for the
1738    /// auto-wrap path, which doesn't have columns in this sense).
1739    pub col_pitch: f64,
1740}
1741
1742/// Per-key horizontal pitch (swatch + spacing + label + gap): the larger of
1743/// today's crude per-character advance (`draw_discrete_legend_at`'s
1744/// `cur_x` formula) and a `text_width`-based measurement, so a label that
1745/// ties under both estimates stays on the byte-identical single-row path.
1746fn legend_pitch(label: &str, theme: &Theme) -> f64 {
1747    let swatch = theme.legend_key_width;
1748    let spacing = theme.legend_spacing;
1749    let text_size = theme.legend_text.size;
1750    // #413: `legend.key.spacing.x` replaces the gaps after the label.
1751    let gap = theme.legend_key_spacing_x;
1752    let legacy = swatch
1753        + spacing
1754        + (label.chars().count() as f64) * text_size * 0.62
1755        + gap.unwrap_or(spacing * 3.0);
1756    let measured =
1757        swatch + spacing + crate::render::text::text_width(label, text_size) + gap.unwrap_or(8.0);
1758    legacy.max(measured)
1759}
1760
1761/// Plan a non-default legend layout (B3-2, D7). `avail_width` is the
1762/// horizontal room for a horizontal legend's flow-pack / overflow check
1763/// (`plot_area.x + plot_area.width - legend_x`); `avail_height` only feeds
1764/// the default `max_rows` formula. Returns `None` when nothing changes:
1765/// no explicit `nrow`/`ncol`, and (for a horizontal legend) the one-line
1766/// layout already fits — or the legend is vertical, where auto-wrap never
1767/// applies (D7 is horizontal-only; `nrow`/`ncol` still work on either).
1768pub(crate) fn plan_legend(
1769    breaks: &[(f64, String)],
1770    guide: &GuideLegend,
1771    theme: &Theme,
1772    avail_width: f64,
1773    avail_height: f64,
1774    is_horizontal: bool,
1775) -> Option<LegendPlan> {
1776    let n = breaks.len();
1777    if n == 0 {
1778        return None;
1779    }
1780
1781    // Explicit grid: ggplot2's `nrow`/`ncol`, column-major unless `byrow`.
1782    if guide.nrow.is_some() || guide.ncol.is_some() {
1783        let (rows, cols) = match (guide.nrow, guide.ncol) {
1784            (Some(nrow), Some(ncol)) => (nrow, ncol),
1785            (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
1786            (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
1787            (None, None) => unreachable!("checked above"),
1788        };
1789        let rows = rows.max(1);
1790        let cols = cols.max(1);
1791        let col_pitch = breaks
1792            .iter()
1793            .map(|(_, l)| legend_pitch(l, theme))
1794            .fold(0.0_f64, f64::max);
1795        let entries = (0..n)
1796            .map(|i| {
1797                let (row, col) = if guide.byrow {
1798                    (i / cols, i % cols)
1799                } else {
1800                    (i % rows, i / rows)
1801                };
1802                LegendEntry {
1803                    break_idx: i,
1804                    row,
1805                    x_offset: col as f64 * col_pitch,
1806                    label_override: None,
1807                }
1808            })
1809            .collect();
1810        return Some(LegendPlan {
1811            rows,
1812            entries,
1813            overflow: None,
1814            cols,
1815            col_pitch,
1816        });
1817    }
1818
1819    // Auto mode: D7 scopes auto-wrap to horizontal legends only.
1820    if !is_horizontal {
1821        return None;
1822    }
1823
1824    let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1825    let total_width: f64 = pitches.iter().sum();
1826    if total_width <= avail_width.max(0.0) {
1827        return None; // fits on one line — byte-identical to 0.21.
1828    }
1829
1830    let item_height = theme.legend_key_height.max(1.0);
1831    let row_limit = guide
1832        .max_rows
1833        .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1834        .max(1);
1835
1836    let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1837    let mut row = 0usize;
1838    let mut cur_x = 0.0_f64;
1839    for (i, &p) in pitches.iter().enumerate() {
1840        if cur_x > 0.0 && cur_x + p > avail_width {
1841            if row + 1 >= row_limit {
1842                break; // out of rows — the rest overflow into "+N more".
1843            }
1844            row += 1;
1845            cur_x = 0.0;
1846        }
1847        entries.push(LegendEntry {
1848            break_idx: i,
1849            row,
1850            x_offset: cur_x,
1851            label_override: None,
1852        });
1853        cur_x += p;
1854    }
1855
1856    if entries.len() >= n {
1857        return Some(LegendPlan {
1858            rows: row + 1,
1859            entries,
1860            overflow: None,
1861            cols: 1,
1862            col_pitch: 0.0,
1863        });
1864    }
1865
1866    // Overflow: make room for "+{k} more" on the last row, dropping
1867    // entries from its tail until it fits (or the row is empty).
1868    let mut dropped = n - entries.len();
1869    loop {
1870        let text = format!("+{dropped} more");
1871        let more_pitch = legend_pitch(&text, theme);
1872        let row_count = entries.iter().filter(|e| e.row == row).count();
1873        let last_x = entries
1874            .iter()
1875            .filter(|e| e.row == row)
1876            .map(|e| e.x_offset + pitches[e.break_idx])
1877            .fold(0.0_f64, f64::max);
1878        if last_x + more_pitch <= avail_width || row_count == 0 {
1879            return Some(LegendPlan {
1880                rows: row + 1,
1881                entries,
1882                overflow: Some(LegendOverflow {
1883                    dropped,
1884                    row,
1885                    x_offset: last_x,
1886                    text,
1887                }),
1888                cols: 1,
1889                col_pitch: 0.0,
1890            });
1891        }
1892        entries.pop();
1893        dropped += 1;
1894    }
1895}
1896
1897/// `Color`/`Fill` are the only aesthetics whose *continuous* legend draws
1898/// as an actual colourbar (`draw_continuous_legend_at`, fixed width). Every
1899/// other continuous legend (`Size`, `Linewidth`, `Alpha`, …) draws sampled
1900/// breaks the same way a discrete guide's keys do
1901/// (`draw_discrete_legend_at` / `draw_discrete_legend_grid`), so it needs
1902/// the same B3-2 wrap/grid planning a discrete guide gets rather than a
1903/// fixed-width reservation — sipemu/ggplot-rs#545: previously this wasn't
1904/// planned *at all*, so a `Size`/`Linewidth` legend with enough keys to
1905/// overflow a narrow `Top`/`Bottom` row just drew past the panel/canvas
1906/// edge with no wrap, shrink, or "+N more" to catch it.
1907pub(crate) fn is_colorbar_aesthetic(aes: &Aesthetic) -> bool {
1908    matches!(aes, Aesthetic::Color | Aesthetic::Fill)
1909}
1910
1911/// How many of a plot's legend scales get B3-2's wrap/grid planning — every
1912/// discrete guide, plus a continuous guide that isn't a colourbar (#545:
1913/// its sampled breaks wrap/shrink exactly like a discrete guide's keys do)
1914/// — used to split the available horizontal room among them when several
1915/// sit side by side (sipemu/ggplot-rs#155).
1916fn wrap_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1917    legend_scales
1918        .iter()
1919        .filter(|aes| {
1920            let is_discrete = scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false);
1921            is_discrete || !is_colorbar_aesthetic(aes)
1922        })
1923        .count()
1924        .max(1)
1925}
1926
1927/// The fixed horizontal width one colourbar guide reserves in a
1928/// horizontal row — the same per-guide step `draw_legend_planned` already
1929/// advances `offset_x` by once it has drawn one (see its `is_horizontal`
1930/// arm for a colourbar).
1931fn colorbar_guide_width(theme: &Theme, bar: &GuideColourbar) -> f64 {
1932    // #211: only used where a colourbar guide shares a *horizontal* row
1933    // with another one — its footprint there is the horizontal bar's
1934    // own length (ticks/labels sit below it now, not to its side), the
1935    // same width `draw_legend_planned` steps `offset_x` by.
1936    let (bar_len, _) = continuous_bar_dims(theme, true, bar);
1937    bar_len + theme.legend_spacing * 2.0
1938}
1939
1940/// The width the plot's true colourbars (continuous `Color`/`Fill`
1941/// scales) reserve in a horizontal row: each its own
1942/// [`colorbar_guide_width`], with its own `guide_colourbar` size (#409).
1943/// (#170 / review #167 finding 7: a bottom legend mixing a colourbar with
1944/// a discrete guide ignored the colourbar's share, so the discrete guide's
1945/// auto-wrap/grid was planned against the *whole* row and could still run
1946/// off the right edge.)
1947fn colorbar_guides_width(
1948    scales: &ScaleSet,
1949    legend_scales: &[Aesthetic],
1950    theme: &Theme,
1951    guide: &GuideLegend,
1952) -> f64 {
1953    legend_scales
1954        .iter()
1955        .filter(|aes| {
1956            is_colorbar_aesthetic(aes) && !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false)
1957        })
1958        .map(|aes| colorbar_guide_width(theme, &guide.colourbar_for(aes)))
1959        .sum()
1960}
1961
1962/// Plan every wrap-eligible legend scale's B3-2 layout (explicit
1963/// `nrow`/`ncol` grid, or auto-wrap) independently (sipemu/ggplot-rs#155):
1964/// previously this only ran for the plot's single discrete guide, leaving
1965/// a second simultaneous discrete guide (e.g. both `colour` and `shape`
1966/// mapped to discrete variables) always on the unwrapped 0.21 path. A
1967/// guide is wrap-eligible when it's discrete, or continuous but not a
1968/// colourbar (#545: `Size`/`Linewidth`/… sampled breaks wrap/shrink the
1969/// same way a discrete guide's keys do — only a true continuous
1970/// `Color`/`Fill` colourbar is exempt). When several wrap-eligible guides
1971/// sit side by side (`is_horizontal`), the available width is split evenly
1972/// among them first, so each one wraps against its own share rather than
1973/// the whole row (which would let it claim more room than its neighbours
1974/// leave). Guides that stack vertically instead each get the full
1975/// `avail_width`, since they don't compete for it. Returns one entry per
1976/// wrap-eligible guide that needs anything beyond the single-row/single-
1977/// column path — a guide absent from the map is byte-identical to
1978/// 0.21/0.22.
1979#[allow(clippy::too_many_arguments)]
1980pub(crate) fn plan_all_legends(
1981    scales: &ScaleSet,
1982    guide: &GuideLegend,
1983    theme: &Theme,
1984    suppressed: &std::collections::HashSet<Aesthetic>,
1985    avail_width: f64,
1986    avail_height: f64,
1987    is_horizontal: bool,
1988    warnings: &mut Vec<String>,
1989) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1990    let legend_scales = legend_scales_for(scales, suppressed);
1991    let n_wrap = wrap_guide_count(scales, &legend_scales);
1992    let per_guide_width = if is_horizontal {
1993        // #170: leave the wrap-planned guides only the width left over
1994        // after every colourbar guide's own fixed share (a continuous
1995        // Color/Fill legend sitting in the same row) — previously
1996        // `avail_width` was split among the discrete guides alone, so a
1997        // colourbar plus a discrete guide could together still overflow
1998        // the row. #545: a continuous, non-colourbar guide (e.g. `Size`/
1999        // `Linewidth`) is now one of the guides this width is split among
2000        // too, rather than drawing unconstrained.
2001        let colorbar_width = colorbar_guides_width(scales, &legend_scales, theme, guide);
2002        ((avail_width - colorbar_width) / n_wrap as f64).max(0.0)
2003    } else {
2004        avail_width
2005    };
2006
2007    let mut plans = std::collections::HashMap::new();
2008    for aes in &legend_scales {
2009        let Some(scale) = scales.get(aes) else {
2010            continue;
2011        };
2012        // #545: plan a continuous, non-colourbar guide (`Size`/
2013        // `Linewidth`/… sampled breaks) exactly like a discrete one —
2014        // only a true colourbar (continuous `Color`/`Fill`) is exempt
2015        // from B3-2's wrap/grid planning.
2016        if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
2017            continue;
2018        }
2019        // `guide_bins()` (#409) lays its keys out on its own.
2020        if guide.bins_for(aes, scale).is_some() {
2021            continue;
2022        }
2023        let breaks = scale.breaks();
2024        let validated_guide = guide.validated(breaks.len(), warnings);
2025        // #271: size against `breaks_for_layout()`, not `breaks` itself —
2026        // identical for every scale except `ScaleFillBinned` under
2027        // `count_reserve`, where it reserves width for the widest count
2028        // digit width so the resulting row count doesn't depend on which
2029        // frame's counts happen to be measured.
2030        let layout_breaks = scale.breaks_for_layout();
2031        if let Some(plan) = plan_legend(
2032            &layout_breaks,
2033            &validated_guide,
2034            theme,
2035            per_guide_width,
2036            avail_height,
2037            is_horizontal,
2038        ) {
2039            plans.insert(aes.clone(), plan);
2040        }
2041    }
2042    plans
2043}
2044
2045/// Draw all legends for the plot, honouring each discrete guide's B3-2
2046/// `plan_legend` layout (explicit `nrow`/`ncol`, or an overflowing
2047/// horizontal legend's auto-wrap) when it has one — several simultaneous
2048/// discrete guides each get their own independent plan
2049/// (sipemu/ggplot-rs#155). A guide missing from `legend_plans` falls back
2050/// to the unchanged [`draw_legend`] path — same calls, same order — which
2051/// keeps this byte-identical to 0.21/0.22 for every plot nothing in
2052/// `legend_plans` touches. Used by `renderer.rs` (single and faceted) and
2053/// `compose::capture_legend` (PlotGrid-collected legends).
2054/// An inside legend's own bounding box ([`LegendPosition::Inside`] only —
2055/// every other position reserves a dedicated band outside `plot_area`, so
2056/// it can never overlap panel-corner furniture living inside it), without
2057/// drawing anything (#273). `renderer.rs` calls this *before* any geom
2058/// draws, so a corner-furniture geom drawn first (`annotation_scale`/
2059/// `annotation_attribution`/`bivariate_legend`) can still stack clear of
2060/// where the legend — drawn last, see `draw_legend_planned` — will land.
2061/// Mirrors that function's own prefix (`legend_scales_for` →
2062/// `group_mergeable_legends` → `estimate_legend_box` → `legend_position`,
2063/// with `legend_extra_rows` fixed at `0` since `Inside` never reads it)
2064/// so this never disagrees with where the legend is actually drawn.
2065pub(crate) fn inside_legend_box(
2066    scales: &ScaleSet,
2067    theme: &Theme,
2068    plot_area: &Rect,
2069    canvas: &Rect,
2070    guide: &GuideLegend,
2071    suppressed: &std::collections::HashSet<Aesthetic>,
2072    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2073) -> Option<Rect> {
2074    if !matches!(theme.legend_position, LegendPosition::Inside(..)) {
2075        return None;
2076    }
2077    let legend_scales = legend_scales_for(scales, suppressed);
2078    if legend_scales.is_empty() {
2079        return None;
2080    }
2081    let groups = group_mergeable_legends(scales, &legend_scales, guide);
2082    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2083    let legend_size_hint =
2084        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
2085    let lift = inside_title_lift(scales, &representatives, theme, guide);
2086    let (legend_x, legend_y, _) =
2087        legend_position(theme, plot_area, 0, legend_size_hint, lift, canvas);
2088    // The visible box, title included (#377).
2089    Some(Rect {
2090        x: legend_x,
2091        y: legend_y - lift,
2092        width: legend_size_hint.0,
2093        height: legend_size_hint.1 + lift,
2094    })
2095}
2096
2097/// The sides (right, left, top, bottom) a `guide_position` (#409) moved a
2098/// drawn legend to, away from `main` — each needs its own band.
2099pub(crate) fn extra_legend_sides(
2100    scales: &ScaleSet,
2101    suppressed: &std::collections::HashSet<Aesthetic>,
2102    guide: &GuideLegend,
2103    main: &LegendPosition,
2104) -> [bool; 4] {
2105    let mut sides = [false; 4];
2106    if guide.positions.is_empty() || matches!(main, LegendPosition::None) {
2107        return sides;
2108    }
2109    for aes in legend_scales_for(scales, suppressed) {
2110        match guide.position_for(&aes, main) {
2111            p if p == main => {}
2112            LegendPosition::Right => sides[0] = true,
2113            LegendPosition::Left => sides[1] = true,
2114            LegendPosition::Top => sides[2] = true,
2115            LegendPosition::Bottom => sides[3] = true,
2116            LegendPosition::None | LegendPosition::Inside(..) => {}
2117        }
2118    }
2119    sides
2120}
2121
2122/// Draw every legend: those at the theme's `legend_position`, then each
2123/// group a `guide_position` (#409) moved to another side, drawn as if the
2124/// theme put the legend there.
2125#[allow(clippy::too_many_arguments)]
2126pub(crate) fn draw_legend_planned(
2127    scales: &ScaleSet,
2128    theme: &Theme,
2129    plot_area: &Rect,
2130    legend_avail_width: f64,
2131    backend: &mut dyn DrawBackend,
2132    guide: &GuideLegend,
2133    suppressed: &std::collections::HashSet<Aesthetic>,
2134    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2135) -> Result<(), RenderError> {
2136    let main = &theme.legend_position;
2137    let drawn = legend_scales_for(scales, suppressed);
2138    let mut others: Vec<LegendPosition> = Vec::new();
2139    if !matches!(main, LegendPosition::None) {
2140        for aes in &drawn {
2141            let p = guide.position_for(aes, main);
2142            if p != main && !matches!(p, LegendPosition::None) && !others.contains(p) {
2143                others.push(p.clone());
2144            }
2145        }
2146    }
2147    if others.is_empty() {
2148        return draw_legend_side(
2149            scales,
2150            theme,
2151            plot_area,
2152            legend_avail_width,
2153            backend,
2154            guide,
2155            suppressed,
2156            legend_plans,
2157        );
2158    }
2159    // Everything not at `at` joins the suppressed set for that pass.
2160    let without = |at: &LegendPosition| -> std::collections::HashSet<Aesthetic> {
2161        let mut s = suppressed.clone();
2162        s.extend(
2163            drawn
2164                .iter()
2165                .filter(|a| guide.position_for(a, main) != at)
2166                .cloned(),
2167        );
2168        s
2169    };
2170    draw_legend_side(
2171        scales,
2172        theme,
2173        plot_area,
2174        legend_avail_width,
2175        backend,
2176        guide,
2177        &without(main),
2178        legend_plans,
2179    )?;
2180    let moved_guide = GuideLegend {
2181        custom: Vec::new(),
2182        ..guide.clone()
2183    };
2184    for at in &others {
2185        let side_theme = Theme {
2186            legend_position: at.clone(),
2187            ..theme.clone()
2188        };
2189        draw_legend_side(
2190            scales,
2191            &side_theme,
2192            plot_area,
2193            legend_avail_width,
2194            backend,
2195            &moved_guide,
2196            &without(at),
2197            legend_plans,
2198        )?;
2199    }
2200    Ok(())
2201}
2202
2203/// One side's legends (see [`draw_legend_planned`]).
2204#[allow(clippy::too_many_arguments)]
2205fn draw_legend_side(
2206    scales: &ScaleSet,
2207    theme: &Theme,
2208    plot_area: &Rect,
2209    // Review #167 finding 1: the width share each discrete guide was
2210    // *planned* against (the same `avail_width` passed to
2211    // `plan_all_legends`), not `plot_area.width`. For a single plot these
2212    // are the same value in practice, but a PlotGrid's collected legend
2213    // (`compose::capture_legend`) measures itself against a fake
2214    // zero-width panel so its origin lands at (0, 0) — stepping by
2215    // `plot_area.width / n` there always advanced by zero, so the second
2216    // discrete guide (e.g. `shape` after `colour`) was drawn directly on
2217    // top of the first instead of beside it.
2218    legend_avail_width: f64,
2219    backend: &mut dyn DrawBackend,
2220    guide: &GuideLegend,
2221    suppressed: &std::collections::HashSet<Aesthetic>,
2222    // Review #158 finding 2: each discrete guide's plan, computed exactly
2223    // once in `layout_built` (`BuiltPlot::legend_plans`) — drawn verbatim
2224    // here rather than recomputed against this (possibly shorter, post-
2225    // row-reservation) `plot_area`, so the drawn "+N more" always matches
2226    // the warning that plan already produced.
2227    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2228) -> Result<(), RenderError> {
2229    if matches!(theme.legend_position, LegendPosition::None) {
2230        return Ok(());
2231    }
2232
2233    let legend_scales = legend_scales_for(scales, suppressed);
2234    if legend_scales.is_empty() && guide.custom.is_empty() {
2235        return Ok(());
2236    }
2237    // #410: key glyphs are lines / points outside the panel.
2238    backend.set_clipping(false);
2239    let drawn = draw_legend_planned_inner(
2240        scales,
2241        theme,
2242        plot_area,
2243        legend_avail_width,
2244        backend,
2245        guide,
2246        legend_plans,
2247        legend_scales,
2248    );
2249    backend.set_clipping(true);
2250    drawn
2251}
2252
2253#[allow(clippy::too_many_arguments)]
2254fn draw_legend_planned_inner(
2255    scales: &ScaleSet,
2256    theme: &Theme,
2257    plot_area: &Rect,
2258    legend_avail_width: f64,
2259    backend: &mut dyn DrawBackend,
2260    guide: &GuideLegend,
2261    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2262    legend_scales: Vec<Aesthetic>,
2263) -> Result<(), RenderError> {
2264    // #239: group aesthetics whose legend would be visually identical
2265    // (same title, breaks, discrete/continuous-ness) into one combined
2266    // guide, rather than drawing one per aesthetic. Everything below that
2267    // used to iterate `legend_scales` one aesthetic at a time now
2268    // iterates `groups` instead; a group's first member (`representatives`)
2269    // stands in for the whole group wherever only one scale's own
2270    // breaks/title/discreteness is needed (sizing, the B3-2 wrap plan
2271    // lookup, …) — `group_mergeable_legends` only ever groups scales
2272    // whose `breaks()` already match, so any member would do.
2273    let groups = group_mergeable_legends(scales, &legend_scales, guide);
2274    let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2275
2276    // #233: `legend_position`'s `Top` arm needs the same wrapped-row
2277    // count `layout_built` fed `PlotLayout::compute_with_extras` to
2278    // produce this `plot_area` in the first place — recomputed here from
2279    // `legend_plans` (the exact plans that count came from) the same way
2280    // `layout_built` did, so it can never disagree with the band
2281    // `plot_area.y` was actually reserved against. `is_horizontal`'s
2282    // auto value only depends on the position itself (`Top`/`Bottom`),
2283    // overridable by `legend_direction` same as below — computed first
2284    // since `legend_position` needs it to pick `max` (several guides
2285    // sharing a row) vs. `sum` (several guides stacked), the same
2286    // either/or `layout_built` used.
2287    let is_horizontal_hint = match theme.legend_direction {
2288        Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
2289        None => matches!(
2290            theme.legend_position,
2291            LegendPosition::Top | LegendPosition::Bottom
2292        ),
2293    };
2294    let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
2295    // #225: `Inside`'s justification needs the legend's own estimated
2296    // footprint and the full canvas to clamp against — cheap to compute
2297    // unconditionally (ignored by every other position). Estimated from
2298    // `representatives`, not every raw aesthetic, so a merged group's
2299    // height is counted once, not once per aesthetic in it.
2300    let legend_size_hint =
2301        estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
2302    let canvas = backend.total_area();
2303    let (legend_x, legend_y, mut is_horizontal) = legend_position(
2304        theme,
2305        plot_area,
2306        legend_extra_rows,
2307        legend_size_hint,
2308        inside_title_lift(scales, &representatives, theme, guide),
2309        &canvas,
2310    );
2311    if let Some(dir) = theme.legend_direction {
2312        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
2313    }
2314    let n_wrap = wrap_guide_count(scales, &representatives);
2315    // #170: match `plan_all_legends`'s share — a colourbar guide (a
2316    // continuous Color/Fill legend) sharing this row has already claimed
2317    // its own fixed width, so step the wrap-planned guides by what's
2318    // left, not the whole `legend_avail_width`. #545: a continuous,
2319    // non-colourbar guide (`Size`/`Linewidth`/…) is one of the
2320    // wrap-planned guides this is split among now, not an unconstrained
2321    // extra.
2322    let colorbar_width = colorbar_guides_width(scales, &representatives, theme, guide);
2323    let per_guide_width = ((legend_avail_width - colorbar_width) / n_wrap as f64).max(0.0);
2324
2325    let mut offset_y = legend_y;
2326    let mut offset_x = legend_x;
2327
2328    // #413: `legend.box.just` — line the guides up along the stack's cross
2329    // axis (left / centre / right of the widest when stacked, top / centre
2330    // / bottom of the tallest side by side). 0 (the default) draws nothing
2331    // differently.
2332    let just = theme.legend_box_just.clamp(0.0, 1.0);
2333    let cross: Vec<f64> = if just > 0.0 {
2334        representatives
2335            .iter()
2336            .map(|aes| {
2337                if is_horizontal {
2338                    horizontal_guide_height(scales, aes, theme, guide, legend_plans)
2339                } else {
2340                    estimate_guide_size(scales, aes, theme, guide, legend_plans)
2341                        .map_or(0.0, |(w, _)| w)
2342                }
2343            })
2344            .collect()
2345    } else {
2346        vec![0.0; representatives.len()]
2347    };
2348    let cross_max = cross.iter().copied().fold(0.0_f64, f64::max);
2349
2350    for (gi, group) in groups.iter().enumerate() {
2351        let shift = (cross_max - cross[gi]) * just;
2352        let (draw_x, draw_y) = if is_horizontal {
2353            (offset_x, offset_y + shift)
2354        } else {
2355            (offset_x + shift, offset_y)
2356        };
2357        let representative = &group[0];
2358        let rep_scale = scales.get(representative).unwrap();
2359        let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
2360            .iter()
2361            .map(|a| (a.clone(), scales.get(a).unwrap()))
2362            .collect();
2363
2364        if !rep_scale.is_discrete() && is_colorbar_aesthetic(representative) {
2365            // A true colourbar (continuous Color/Fill) draws as a fixed-
2366            // width bar — not in scope for B3-2's wrap/grid planning.
2367            // Every other continuous legend (Size/Linewidth/… sampled
2368            // breaks, #545) falls through to the same wrap/grid-aware
2369            // path a discrete guide uses, below.
2370            //
2371            // #239: a merged continuous fill+colour pair draws one
2372            // bar — `group_mergeable_legends` only merges scales
2373            // whose breaks (so, in practice, colours) already match,
2374            // so the other member(s) would draw the identical bar
2375            // again.
2376            let height = draw_continuous_legend_at(
2377                rep_scale,
2378                theme,
2379                draw_x,
2380                draw_y,
2381                backend,
2382                guide,
2383                is_horizontal,
2384            )?;
2385            if is_horizontal {
2386                // #211: the bar is now drawn along x (wide, not
2387                // narrow) — step by its own length, not the old
2388                // vertical-bar width-plus-side-label estimate.
2389                let (bar_len, _) =
2390                    continuous_bar_dims(theme, true, &guide.colourbar_for(representative));
2391                offset_x += bar_len + theme.legend_spacing * 2.0;
2392            } else {
2393                offset_y += height + theme.legend_spacing * 2.0;
2394            }
2395            continue;
2396        }
2397
2398        // A discrete guide, or a continuous non-colourbar guide (#545:
2399        // `Size`/`Linewidth`/… sampled breaks draw the same way a
2400        // discrete guide's keys do) — both get the same B3-2 wrap/grid
2401        // plan when `plan_all_legends` gave them one.
2402        match legend_plans.get(representative) {
2403            None => {
2404                if is_horizontal {
2405                    let width = draw_discrete_legend_at(
2406                        scales,
2407                        &scale_group,
2408                        theme,
2409                        draw_x,
2410                        draw_y,
2411                        backend,
2412                        guide,
2413                        true,
2414                    )?;
2415                    offset_x += width + theme.legend_spacing * 2.0;
2416                } else {
2417                    let height = draw_discrete_legend_at(
2418                        scales,
2419                        &scale_group,
2420                        theme,
2421                        draw_x,
2422                        draw_y,
2423                        backend,
2424                        guide,
2425                        false,
2426                    )?;
2427                    offset_y += height + theme.legend_spacing * 2.0;
2428                }
2429            }
2430            Some(plan) => {
2431                let height = draw_discrete_legend_grid(
2432                    scales,
2433                    &scale_group,
2434                    theme,
2435                    draw_x,
2436                    draw_y,
2437                    backend,
2438                    guide,
2439                    plan,
2440                )?;
2441                if is_horizontal {
2442                    // Each discrete guide gets an equal share of the row
2443                    // (sipemu/ggplot-rs#155) rather than the whole row,
2444                    // which only held for exactly one discrete guide.
2445                    offset_x += per_guide_width;
2446                } else {
2447                    offset_y += height + theme.legend_spacing * 2.0;
2448                }
2449            }
2450        }
2451    }
2452
2453    // #409: custom guides (`guide_custom()`), after the scales' own.
2454    let family = (!theme.legend_title.family.is_empty()).then(|| theme.legend_title.family.clone());
2455    for custom in &guide.custom {
2456        let title_offset = custom.title_offset(theme);
2457        if let Some(title) = custom.title.as_deref().filter(|t| !t.is_empty()) {
2458            backend.set_element_class(Some("gg-legend gg-legend-title"));
2459            crate::render::rich::draw(
2460                backend,
2461                theme.rich_text,
2462                title,
2463                (offset_x, offset_y),
2464                &TextStyle {
2465                    color: theme.legend_title.color,
2466                    size: theme.legend_title.size,
2467                    anchor: TextAnchor::Start,
2468                    angle: 0.0,
2469                    family: family.clone(),
2470                    face: theme.legend_title.face,
2471                    bg_colour: None,
2472                    bg_r: 0.0,
2473                },
2474            )?;
2475        }
2476        let rect = Rect {
2477            x: offset_x,
2478            y: offset_y + title_offset,
2479            width: custom.width,
2480            height: custom.height,
2481        };
2482        backend.set_element_class(Some("gg-legend gg-legend-custom"));
2483        (custom.draw)(backend, &rect)?;
2484        if is_horizontal {
2485            offset_x += custom.width + theme.legend_spacing * 2.0;
2486        } else {
2487            offset_y += title_offset + custom.height + theme.legend_spacing * 2.0;
2488        }
2489    }
2490    Ok(())
2491}
2492
2493/// Draw a discrete legend using a [`LegendPlan`] (explicit grid or
2494/// auto-wrap). Returns the total height used (including any "+N more"
2495/// row).
2496#[allow(clippy::too_many_arguments)]
2497/// Wrapped-grid counterpart of [`draw_discrete_legend_at`] — same `group`
2498/// merging (#239).
2499fn draw_discrete_legend_grid(
2500    scales: &ScaleSet,
2501    group: &[(Aesthetic, &dyn crate::scale::Scale)],
2502    theme: &Theme,
2503    legend_x: f64,
2504    legend_y: f64,
2505    backend: &mut dyn DrawBackend,
2506    guide: &GuideLegend,
2507    plan: &LegendPlan,
2508) -> Result<f64, RenderError> {
2509    let (_, scale) = &group[0];
2510    let mut breaks = scale.breaks();
2511    if breaks.is_empty() {
2512        return Ok(0.0);
2513    }
2514    if guide.is_reversed(&group[0].0) {
2515        breaks.reverse();
2516    }
2517
2518    // #236: same key-box growth as `draw_discrete_legend_at`.
2519    let diameter = group
2520        .iter()
2521        .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
2522        .fold(0.0_f64, f64::max);
2523    let swatch_size = theme.legend_key_width.max(diameter);
2524    let item_height = theme.legend_key_height.max(swatch_size);
2525    // #413: `legend.key.spacing.y` between rows; `row_pitch` steps them.
2526    let gap_y = theme.legend_key_gap_y();
2527    let row_pitch = item_height + gap_y;
2528
2529    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
2530    let legend_family = if theme.legend_title.family.is_empty() {
2531        None
2532    } else {
2533        Some(theme.legend_title.family.clone())
2534    };
2535    let title_offset = if !title.is_empty() {
2536        backend.set_element_class(Some("gg-legend gg-legend-title"));
2537        crate::render::rich::draw(
2538            backend,
2539            theme.rich_text,
2540            title,
2541            (legend_x, legend_y),
2542            &TextStyle {
2543                color: theme.legend_title.color,
2544                size: theme.legend_title.size,
2545                anchor: TextAnchor::Start,
2546                angle: 0.0,
2547                family: legend_family,
2548                face: theme.legend_title.face,
2549                bg_colour: None,
2550                bg_r: 0.0,
2551            },
2552        )?;
2553        theme.legend_title.size + 4.0
2554    } else {
2555        0.0
2556    };
2557    let items_y = legend_y + title_offset;
2558
2559    for entry in &plan.entries {
2560        let (pos, label) = &breaks[entry.break_idx];
2561        let x = legend_x + entry.x_offset;
2562        let y = items_y + entry.row as f64 * row_pitch;
2563        let center_x = x + swatch_size / 2.0;
2564        let center_y = y + swatch_size / 2.0;
2565
2566        if theme.legend_key.visible {
2567            if let Some(fill) = theme.legend_key.fill {
2568                backend.set_element_class(Some("gg-legend gg-legend-key"));
2569                backend.draw_rect(
2570                    (x, y),
2571                    (x + swatch_size, y + swatch_size),
2572                    &RectStyle {
2573                        fill: Some(fill),
2574                        stroke: theme.legend_key.color,
2575                        stroke_width: theme.legend_key.width,
2576                        alpha: 1.0,
2577                        clip: false,
2578                        corner_radius: 0.0,
2579                    },
2580                )?;
2581            }
2582        }
2583
2584        backend.set_element_class(Some("gg-legend gg-legend-key"));
2585        draw_group_keys(
2586            backend,
2587            scales,
2588            group,
2589            theme,
2590            guide,
2591            label,
2592            *pos,
2593            (x, y, center_x, center_y),
2594            swatch_size,
2595        )?;
2596
2597        let label_family = if theme.legend_text.family.is_empty() {
2598            None
2599        } else {
2600            Some(theme.legend_text.family.clone())
2601        };
2602        // #159: a label truncated to fit a vertical multi-column grid
2603        // still shows its full text via a `<title>` tooltip (backends
2604        // without tooltip support just render the truncated text).
2605        let display = entry.label_override.as_deref().unwrap_or(label.as_str());
2606        backend.set_element_class(Some("gg-legend gg-legend-text"));
2607        backend.draw_text_with_title(
2608            display,
2609            (x + swatch_size + theme.legend_spacing, center_y),
2610            &TextStyle {
2611                color: theme.legend_text.color,
2612                size: theme.legend_text.size,
2613                anchor: TextAnchor::Start,
2614                angle: 0.0,
2615                family: label_family,
2616                face: theme.legend_text.face,
2617                bg_colour: None,
2618                bg_r: 0.0,
2619            },
2620            entry.label_override.as_deref().map(|_| label.as_str()),
2621        )?;
2622    }
2623
2624    // The overflow marker is text only — not a mark, no swatch.
2625    if let Some(ov) = &plan.overflow {
2626        let x = legend_x + ov.x_offset;
2627        let y = items_y + ov.row as f64 * row_pitch + swatch_size / 2.0;
2628        let label_family = if theme.legend_text.family.is_empty() {
2629            None
2630        } else {
2631            Some(theme.legend_text.family.clone())
2632        };
2633        backend.set_element_class(Some("gg-legend gg-legend-text"));
2634        crate::render::rich::draw(
2635            backend,
2636            theme.rich_text,
2637            &ov.text,
2638            (x, y),
2639            &TextStyle {
2640                color: theme.legend_text.color,
2641                size: theme.legend_text.size,
2642                anchor: TextAnchor::Start,
2643                angle: 0.0,
2644                family: label_family,
2645                face: theme.legend_text.face,
2646                bg_colour: None,
2647                bg_r: 0.0,
2648            },
2649        )?;
2650    }
2651
2652    Ok(title_offset + plan.rows as f64 * row_pitch - gap_y)
2653}
2654
2655#[cfg(test)]
2656mod group_mergeable_legends_tests {
2657    use super::*;
2658    use crate::scale::color::RGBAColor;
2659    use crate::scale::manual::ScaleManual;
2660
2661    #[test]
2662    fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
2663        // #239: in the normal flow, `group_mergeable_legends` is only ever
2664        // handed `legend_scales_for`'s output, which already filters to
2665        // aesthetics present in `scales` — so the `let-else`'s `continue`
2666        // branch (an aesthetic that slipped through with no attached
2667        // scale) is otherwise unreachable from `draw_legend_planned`.
2668        // Exercise it directly: `Fill` is listed but has no scale.
2669        let mut scales = ScaleSet::new();
2670        scales.add(Box::new(ScaleManual::new(
2671            Aesthetic::Color,
2672            vec![("a", RGBAColor::new(200, 0, 0))],
2673        )));
2674        let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
2675        let guide = GuideLegend::default();
2676        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2677        assert_eq!(groups, vec![vec![Aesthetic::Color]]);
2678    }
2679}
2680
2681/// #211: direct unit coverage for the two private helpers
2682/// `draw_continuous_legend_at`'s vertical/horizontal split relies on —
2683/// exercised indirectly by every colourbar-drawing integration test
2684/// already, but called here directly too so their own branches/math
2685/// are pinned regardless of how the compiler happens to inline them.
2686#[cfg(test)]
2687mod continuous_bar_dims_tests {
2688    use super::*;
2689
2690    #[test]
2691    fn dims_swap_between_vertical_and_horizontal() {
2692        let theme = Theme::default();
2693        let (w, h) = continuous_bar_dims(&theme, false, &GuideColourbar::default());
2694        assert_eq!(w, theme.legend_key_width);
2695        assert_eq!(h, theme.legend_key_height * 8.0);
2696
2697        let (w, h) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
2698        assert_eq!(w, theme.legend_key_height * 8.0);
2699        assert_eq!(h, theme.legend_key_width);
2700    }
2701
2702    #[test]
2703    fn horizontal_bar_height_adds_a_title_line_only_when_there_is_a_title() {
2704        let theme = Theme::default();
2705        let without_title =
2706            continuous_horizontal_bar_height(&theme, false, &GuideColourbar::default());
2707        let with_title = continuous_horizontal_bar_height(&theme, true, &GuideColourbar::default());
2708        assert!(with_title > without_title);
2709        assert!(((with_title - without_title) - (theme.legend_title.size + 4.0)).abs() < 1e-9);
2710
2711        // Matches `continuous_bar_dims`'s own horizontal thickness plus
2712        // the fixed tick/spacing/label overhead — not just a positive
2713        // number, the exact formula `draw_continuous_legend_at` relies
2714        // on to never disagree with what `plot.rs` reserves for it.
2715        let (_, thickness) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
2716        let tick_len = 3.0;
2717        let label_line = theme.legend_text.size + 4.0;
2718        assert!(
2719            (without_title - (thickness + tick_len + theme.legend_spacing + label_line)).abs()
2720                < 1e-9
2721        );
2722    }
2723}
2724
2725/// #273: `inside_legend_box` — the pre-draw measurement `renderer.rs` uses
2726/// to claim an inside legend's box before any corner-furniture geom draws.
2727#[cfg(test)]
2728mod inside_legend_box_tests {
2729    use super::*;
2730    use crate::scale::color::RGBAColor;
2731    use crate::scale::manual::ScaleManual;
2732
2733    fn scales_with_a_legend() -> ScaleSet {
2734        let mut scales = ScaleSet::new();
2735        scales.add(Box::new(ScaleManual::new(
2736            Aesthetic::Color,
2737            vec![
2738                ("a", RGBAColor::new(200, 0, 0)),
2739                ("b", RGBAColor::new(0, 0, 200)),
2740            ],
2741        )));
2742        scales
2743    }
2744
2745    fn areas() -> (Rect, Rect) {
2746        let plot_area = Rect {
2747            x: 10.0,
2748            y: 10.0,
2749            width: 200.0,
2750            height: 150.0,
2751        };
2752        let canvas = Rect {
2753            x: 0.0,
2754            y: 0.0,
2755            width: 220.0,
2756            height: 170.0,
2757        };
2758        (plot_area, canvas)
2759    }
2760
2761    #[test]
2762    fn non_inside_positions_claim_nothing() {
2763        let scales = scales_with_a_legend();
2764        let (plot_area, canvas) = areas();
2765        let guide = GuideLegend::default();
2766        let suppressed = std::collections::HashSet::new();
2767        let legend_plans = std::collections::HashMap::new();
2768        for pos in [
2769            LegendPosition::Right,
2770            LegendPosition::Left,
2771            LegendPosition::Top,
2772            LegendPosition::Bottom,
2773            LegendPosition::None,
2774        ] {
2775            let theme = Theme {
2776                legend_position: pos,
2777                ..Theme::default()
2778            };
2779            assert!(inside_legend_box(
2780                &scales,
2781                &theme,
2782                &plot_area,
2783                &canvas,
2784                &guide,
2785                &suppressed,
2786                &legend_plans
2787            )
2788            .is_none());
2789        }
2790    }
2791
2792    #[test]
2793    fn no_legend_scales_claims_nothing_even_when_inside() {
2794        let scales = ScaleSet::new(); // nothing mapped to a legend aesthetic
2795        let (plot_area, canvas) = areas();
2796        let theme = Theme {
2797            legend_position: LegendPosition::Inside(0.85, 0.85),
2798            ..Theme::default()
2799        };
2800        let guide = GuideLegend::default();
2801        let suppressed = std::collections::HashSet::new();
2802        let legend_plans = std::collections::HashMap::new();
2803        assert!(inside_legend_box(
2804            &scales,
2805            &theme,
2806            &plot_area,
2807            &canvas,
2808            &guide,
2809            &suppressed,
2810            &legend_plans
2811        )
2812        .is_none());
2813    }
2814
2815    #[test]
2816    fn inside_position_with_a_legend_claims_a_box_within_the_panel() {
2817        let scales = scales_with_a_legend();
2818        let (plot_area, canvas) = areas();
2819        let theme = Theme {
2820            legend_position: LegendPosition::Inside(0.85, 0.85),
2821            ..Theme::default()
2822        };
2823        let guide = GuideLegend::default();
2824        let suppressed = std::collections::HashSet::new();
2825        let legend_plans = std::collections::HashMap::new();
2826        let rect = inside_legend_box(
2827            &scales,
2828            &theme,
2829            &plot_area,
2830            &canvas,
2831            &guide,
2832            &suppressed,
2833            &legend_plans,
2834        )
2835        .expect("an Inside legend with a mapped scale claims a box");
2836        assert!(rect.width > 0.0 && rect.height > 0.0);
2837        assert!(rect.x >= canvas.x - 1e-6 && rect.x + rect.width <= canvas.x + canvas.width + 1e-6);
2838        assert!(
2839            rect.y >= canvas.y - 1e-6 && rect.y + rect.height <= canvas.y + canvas.height + 1e-6
2840        );
2841    }
2842
2843    #[test]
2844    fn matches_the_position_draw_legend_planned_itself_uses() {
2845        // Mirrors `draw_legend_planned`'s own prefix (`legend_scales_for`
2846        // → `group_mergeable_legends` → `estimate_legend_box` →
2847        // `legend_position`) — this must agree with it, or a geom drawn
2848        // before the legend could stack clear of the wrong spot.
2849        let scales = scales_with_a_legend();
2850        let (plot_area, canvas) = areas();
2851        let theme = Theme {
2852            legend_position: LegendPosition::Inside(0.85, 0.85),
2853            ..Theme::default()
2854        };
2855        let guide = GuideLegend::default();
2856        let suppressed = std::collections::HashSet::new();
2857        let legend_plans = std::collections::HashMap::new();
2858
2859        let rect = inside_legend_box(
2860            &scales,
2861            &theme,
2862            &plot_area,
2863            &canvas,
2864            &guide,
2865            &suppressed,
2866            &legend_plans,
2867        )
2868        .unwrap();
2869
2870        let legend_scales = legend_scales_for(&scales, &suppressed);
2871        let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2872        let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2873        let size_hint =
2874            estimate_legend_box(&scales, &representatives, &theme, &guide, &legend_plans);
2875        let lift = inside_title_lift(&scales, &representatives, &theme, &guide);
2876        let (x, y, _) = legend_position(&theme, &plot_area, 0, size_hint, lift, &canvas);
2877
2878        // The visible box: the title's upper half above the origin (#377).
2879        assert_eq!(
2880            (rect.x, rect.y, rect.width, rect.height),
2881            (x, y - lift, size_hint.0, size_hint.1 + lift)
2882        );
2883    }
2884}