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