Skip to main content

ggplot_rs/guide/
legend.rs

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