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/// The aesthetics that would get a legend, in the same order and with the
23/// same color/fill de-duplication `draw_legend` and `draw_legend_planned`
24/// use. Shared with `layout_built` (B3-2), so the "is this the single
25/// discrete guide D7 requires" check is the same one the drawing code
26/// uses.
27pub(crate) fn legend_scales_for(
28    scales: &ScaleSet,
29    suppressed: &std::collections::HashSet<Aesthetic>,
30) -> Vec<Aesthetic> {
31    let mut legend_scales: Vec<Aesthetic> = Vec::new();
32    for aes in LEGEND_AESTHETICS {
33        if suppressed.contains(aes) {
34            continue;
35        }
36        if let Some(scale) = scales.get(aes) {
37            if !scale.breaks().is_empty() {
38                if *aes == Aesthetic::Fill && legend_scales.contains(&Aesthetic::Color) {
39                    continue;
40                }
41                legend_scales.push(aes.clone());
42            }
43        }
44    }
45    legend_scales
46}
47
48/// Draw all legends for the plot.
49///
50/// No longer called internally: `compose::capture_legend` (the only
51/// caller) was rerouted to `draw_legend_planned` in 0.23 so a `PlotGrid`'s
52/// collected legend gets the same B3-2 wrap/grid treatment a single
53/// plot's legend does (sipemu/ggplot-rs#155). `draw_legend_planned` is
54/// crate-internal (`pub(crate)`), so there is no direct public
55/// replacement — legend drawing is otherwise handled automatically by
56/// `GGPlot`'s own render methods, which is the supported way to draw a
57/// legend; this function was never meant to be called by consumers for
58/// that purpose and will be removed in a later minor release.
59#[deprecated(
60    since = "0.23.0",
61    note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
62             sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
63             by GGPlot's render methods. Will be removed in a later minor release."
64)]
65pub fn draw_legend(
66    scales: &ScaleSet,
67    theme: &Theme,
68    plot_area: &Rect,
69    backend: &mut dyn DrawBackend,
70    guide: &GuideLegend,
71    suppressed: &std::collections::HashSet<Aesthetic>,
72) -> Result<(), RenderError> {
73    if matches!(theme.legend_position, LegendPosition::None) {
74        return Ok(());
75    }
76
77    // Collect all aesthetics that have a scale with breaks
78    let mut legend_scales: Vec<&Aesthetic> = Vec::new();
79    for aes in LEGEND_AESTHETICS {
80        // Skip suppressed aesthetics
81        if suppressed.contains(aes) {
82            continue;
83        }
84        if let Some(scale) = scales.get(aes) {
85            if !scale.breaks().is_empty() {
86                // Don't duplicate Color/Fill if both exist with same breaks
87                if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
88                    continue;
89                }
90                legend_scales.push(aes);
91            }
92        }
93    }
94
95    if legend_scales.is_empty() {
96        return Ok(());
97    }
98
99    // Compute legend origin based on position
100    let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
101    // legend.direction overrides the auto layout from the position.
102    if let Some(dir) = theme.legend_direction {
103        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
104    }
105
106    let mut offset_y = legend_y;
107    let mut offset_x = legend_x;
108
109    for aes in &legend_scales {
110        let scale = scales.get(aes).unwrap();
111
112        if scale.is_discrete() {
113            if is_horizontal {
114                let width = draw_discrete_legend_at(
115                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
116                )?;
117                offset_x += width + theme.legend_spacing * 2.0;
118            } else {
119                let height = draw_discrete_legend_at(
120                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
121                )?;
122                offset_y += height + theme.legend_spacing * 2.0;
123            }
124        } else {
125            // Continuous legend (colorbar) — only for color/fill
126            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
127                let height =
128                    draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
129                if is_horizontal {
130                    offset_x += theme.legend_key_width
131                        + theme.legend_text.size * 6.0
132                        + theme.legend_spacing * 2.0;
133                } else {
134                    offset_y += height + theme.legend_spacing * 2.0;
135                }
136            } else {
137                // Continuous size/alpha — draw as discrete-like with sampled breaks
138                let height = draw_discrete_legend_at(
139                    scales,
140                    aes,
141                    scale,
142                    theme,
143                    offset_x,
144                    offset_y,
145                    backend,
146                    guide,
147                    is_horizontal,
148                )?;
149                if is_horizontal {
150                    offset_x += theme.legend_key_width
151                        + theme.legend_text.size * 6.0
152                        + theme.legend_spacing * 2.0;
153                } else {
154                    offset_y += height + theme.legend_spacing * 2.0;
155                }
156            }
157        }
158    }
159
160    Ok(())
161}
162
163/// Compute legend origin based on position setting.
164/// Returns (x, y, is_horizontal).
165pub(crate) fn legend_position(theme: &Theme, plot_area: &Rect) -> (f64, f64, bool) {
166    match theme.legend_position {
167        LegendPosition::Right => (
168            plot_area.x + plot_area.width + theme.legend_margin.left,
169            plot_area.y + theme.legend_margin.top,
170            false,
171        ),
172        LegendPosition::Left => (
173            theme.legend_margin.left,
174            plot_area.y + theme.legend_margin.top,
175            false,
176        ),
177        LegendPosition::Top => (
178            plot_area.x + theme.legend_margin.left,
179            theme.legend_margin.top,
180            true,
181        ),
182        LegendPosition::Bottom => (
183            plot_area.x + theme.legend_margin.left,
184            plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
185            true,
186        ),
187        LegendPosition::None => (0.0, 0.0, false),
188        LegendPosition::Inside(fx, fy) => (
189            plot_area.x + fx * plot_area.width,
190            plot_area.y + (1.0 - fy) * plot_area.height,
191            false,
192        ),
193    }
194}
195
196/// Draw a discrete legend at a given position. Returns the height used.
197#[allow(clippy::too_many_arguments)]
198fn draw_discrete_legend_at(
199    scales: &ScaleSet,
200    aes: &Aesthetic,
201    scale: &dyn crate::scale::Scale,
202    theme: &Theme,
203    legend_x: f64,
204    legend_y: f64,
205    backend: &mut dyn DrawBackend,
206    guide: &GuideLegend,
207    is_horizontal: bool,
208) -> Result<f64, RenderError> {
209    let mut breaks = scale.breaks();
210    if breaks.is_empty() {
211        return Ok(0.0);
212    }
213
214    // Apply guide reverse
215    if guide.reverse {
216        breaks.reverse();
217    }
218
219    let item_height = theme.legend_key_height;
220    let swatch_size = theme.legend_key_width;
221
222    // Draw legend title (guide title overrides scale name)
223    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
224    let legend_family = if theme.legend_title.family.is_empty() {
225        None
226    } else {
227        Some(theme.legend_title.family.clone())
228    };
229    let title_offset = if !title.is_empty() {
230        backend.draw_text(
231            title,
232            (legend_x, legend_y),
233            &TextStyle {
234                color: theme.legend_title.color,
235                size: theme.legend_title.size,
236                anchor: TextAnchor::Start,
237                angle: 0.0,
238                family: legend_family,
239                face: theme.legend_title.face,
240            },
241        )?;
242        theme.legend_title.size + 4.0
243    } else {
244        0.0
245    };
246
247    let items_y = legend_y + title_offset;
248
249    // Draw legend background
250    if theme.legend_background.visible {
251        let total_height = breaks.len() as f64 * item_height;
252        let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
253        if let Some(fill) = theme.legend_background.fill {
254            backend.draw_rect(
255                (legend_x - 2.0, items_y - 2.0),
256                (legend_x + total_width + 2.0, items_y + total_height + 2.0),
257                &RectStyle {
258                    fill: Some(fill),
259                    stroke: theme.legend_background.color,
260                    stroke_width: theme.legend_background.width,
261                    alpha: 1.0,
262                    clip: false,
263                    corner_radius: 0.0,
264                },
265            )?;
266        }
267    }
268
269    // Horizontal legends lay keys left-to-right; vertical ones stack downward.
270    let mut cur_x = legend_x;
271    for (i, (pos, label)) in breaks.iter().enumerate() {
272        let x = if is_horizontal { cur_x } else { legend_x };
273        let y = if is_horizontal {
274            items_y
275        } else {
276            items_y + i as f64 * item_height
277        };
278        let center_x = x + swatch_size / 2.0;
279        let center_y = y + swatch_size / 2.0;
280
281        // Draw legend key background
282        if theme.legend_key.visible {
283            if let Some(fill) = theme.legend_key.fill {
284                backend.draw_rect(
285                    (x, y),
286                    (x + swatch_size, y + swatch_size),
287                    &RectStyle {
288                        fill: Some(fill),
289                        stroke: theme.legend_key.color,
290                        stroke_width: theme.legend_key.width,
291                        alpha: 1.0,
292                        clip: false,
293                        corner_radius: 0.0,
294                    },
295                )?;
296            }
297        }
298
299        // Draw the appropriate swatch based on aesthetic type
300        draw_legend_swatch(
301            backend,
302            scales,
303            aes,
304            scale,
305            theme,
306            label,
307            *pos,
308            x,
309            y,
310            center_x,
311            center_y,
312            swatch_size,
313        )?;
314
315        // Label
316        let label_family = if theme.legend_text.family.is_empty() {
317            None
318        } else {
319            Some(theme.legend_text.family.clone())
320        };
321        backend.draw_text(
322            label,
323            (x + swatch_size + theme.legend_spacing, center_y),
324            &TextStyle {
325                color: theme.legend_text.color,
326                size: theme.legend_text.size,
327                anchor: TextAnchor::Start,
328                angle: 0.0,
329                family: label_family,
330                face: theme.legend_text.face,
331            },
332        )?;
333        if is_horizontal {
334            // advance past swatch + spacing + estimated label width + gap
335            cur_x = x
336                + swatch_size
337                + theme.legend_spacing
338                + (label.chars().count() as f64) * theme.legend_text.size * 0.62
339                + theme.legend_spacing * 3.0;
340        }
341    }
342
343    if is_horizontal {
344        Ok(cur_x - legend_x) // width used
345    } else {
346        Ok(title_offset + breaks.len() as f64 * item_height)
347    }
348}
349
350/// Draw a continuous colorbar legend at a given position. Returns the height used.
351fn draw_continuous_legend_at(
352    scale: &dyn crate::scale::Scale,
353    theme: &Theme,
354    legend_x: f64,
355    legend_y: f64,
356    backend: &mut dyn DrawBackend,
357    guide: &GuideLegend,
358) -> Result<f64, RenderError> {
359    let breaks = scale.breaks();
360    if breaks.is_empty() {
361        return Ok(0.0);
362    }
363
364    let bar_width = theme.legend_key_width;
365    let bar_height = theme.legend_key_height * 8.0;
366
367    // Draw legend title (guide title overrides scale name)
368    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
369    let cont_family = if theme.legend_title.family.is_empty() {
370        None
371    } else {
372        Some(theme.legend_title.family.clone())
373    };
374    let title_offset = if !title.is_empty() {
375        backend.draw_text(
376            title,
377            (legend_x, legend_y),
378            &TextStyle {
379                color: theme.legend_title.color,
380                size: theme.legend_title.size,
381                anchor: TextAnchor::Start,
382                angle: 0.0,
383                family: cont_family,
384                face: theme.legend_title.face,
385            },
386        )?;
387        theme.legend_title.size + 4.0
388    } else {
389        0.0
390    };
391
392    let bar_top = legend_y + title_offset;
393
394    // Draw legend background
395    if theme.legend_background.visible {
396        let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
397        if let Some(fill) = theme.legend_background.fill {
398            backend.draw_rect(
399                (legend_x - 2.0, bar_top - 2.0),
400                (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
401                &RectStyle {
402                    fill: Some(fill),
403                    stroke: theme.legend_background.color,
404                    stroke_width: theme.legend_background.width,
405                    alpha: 1.0,
406                    clip: false,
407                    corner_radius: 0.0,
408                },
409            )?;
410        }
411    }
412
413    // Draw gradient bar as N thin horizontal slices
414    // Use data-domain values to avoid double-normalization in map_to_color()
415    let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
416    let n_slices = 50;
417    let slice_height = bar_height / n_slices as f64;
418    for i in 0..n_slices {
419        let t = 1.0 - i as f64 / n_slices as f64;
420        let data_val = data_min + t * (data_max - data_min);
421        let color = scale
422            .map_to_color(&Value::Float(data_val))
423            .unwrap_or((127, 127, 127));
424        let sy = bar_top + i as f64 * slice_height;
425        backend.draw_rect(
426            (legend_x, sy),
427            (legend_x + bar_width, sy + slice_height + 0.5),
428            &RectStyle {
429                fill: Some(color),
430                stroke: None,
431                stroke_width: 0.0,
432                alpha: 1.0,
433                clip: false,
434                corner_radius: 0.0,
435            },
436        )?;
437    }
438
439    // Draw border
440    // (Ticks reuse its colour and width even when the frame is hidden.)
441    let border = theme.legend_bar_border_line();
442    let border_style = LineStyle {
443        color: border.color,
444        width: border.width,
445        alpha: 1.0,
446        linetype: border.linetype,
447        ..Default::default()
448    };
449    if border.visible {
450        backend.draw_line(
451            &[
452                (legend_x, bar_top),
453                (legend_x + bar_width, bar_top),
454                (legend_x + bar_width, bar_top + bar_height),
455                (legend_x, bar_top + bar_height),
456                (legend_x, bar_top),
457            ],
458            &border_style,
459        )?;
460    }
461
462    // Draw tick marks and labels
463    let tick_len = 3.0;
464    for (pos, label) in &breaks {
465        let tick_y = bar_top + bar_height * (1.0 - pos);
466        backend.draw_line(
467            &[
468                (legend_x + bar_width, tick_y),
469                (legend_x + bar_width + tick_len, tick_y),
470            ],
471            &border_style,
472        )?;
473        let tick_family = if theme.legend_text.family.is_empty() {
474            None
475        } else {
476            Some(theme.legend_text.family.clone())
477        };
478        backend.draw_text(
479            label,
480            (
481                legend_x + bar_width + tick_len + theme.legend_spacing,
482                tick_y,
483            ),
484            &TextStyle {
485                color: theme.legend_text.color,
486                size: theme.legend_text.size,
487                anchor: TextAnchor::Start,
488                angle: 0.0,
489                family: tick_family,
490                face: theme.legend_text.face,
491            },
492        )?;
493    }
494
495    Ok(title_offset + bar_height)
496}
497
498/// Draw one legend key's swatch (not its label) for `aes`, at `(x, y)`
499/// (top-left) / `(center_x, center_y)` (centre), `swatch_size` square.
500/// Shared by the unchanged [`draw_discrete_legend_at`] and the B3-2
501/// planner-driven [`draw_legend_planned`], so both draw pixel-identical
502/// swatches.
503#[allow(clippy::too_many_arguments)]
504fn draw_legend_swatch(
505    backend: &mut dyn DrawBackend,
506    scales: &ScaleSet,
507    aes: &Aesthetic,
508    scale: &dyn crate::scale::Scale,
509    theme: &Theme,
510    label: &str,
511    pos: f64,
512    x: f64,
513    y: f64,
514    center_x: f64,
515    center_y: f64,
516    swatch_size: f64,
517) -> Result<(), RenderError> {
518    let value = Value::Str(label.to_string());
519    match aes {
520        Aesthetic::Color | Aesthetic::Fill => {
521            let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
522            let style = RectStyle {
523                fill: Some(color),
524                stroke: None,
525                stroke_width: 0.0,
526                alpha: 1.0,
527                clip: false,
528                corner_radius: 0.0,
529            };
530            // #206: the "no data" key draws its scale's na_pattern
531            // instead of a flat swatch, when set.
532            if scale.is_na_break(label) {
533                if let Some(p) = scale.na_fill_pattern() {
534                    return backend.draw_rect_pattern(
535                        (x, y),
536                        (x + swatch_size, y + swatch_size),
537                        &p,
538                        &style,
539                    );
540                }
541            }
542            backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
543        }
544        Aesthetic::Shape => {
545            let shape = scales
546                .map_shape(&value)
547                .unwrap_or(crate::render::backend::PointShape::Circle);
548            backend.draw_shape(
549                (center_x, center_y),
550                swatch_size / 3.0,
551                &PointStyle {
552                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
553                    alpha: 1.0,
554                    filled: true,
555                    shape,
556                },
557            )?;
558        }
559        Aesthetic::Linetype => {
560            let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
561            backend.draw_line(
562                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
563                &LineStyle {
564                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
565                    width: 1.5,
566                    alpha: 1.0,
567                    linetype: lt,
568                    ..Default::default()
569                },
570            )?;
571        }
572        Aesthetic::Size => {
573            let size = scale
574                .map_fraction_to_size(pos)
575                .or_else(|| scales.map_size(&value))
576                .unwrap_or(3.0);
577            backend.draw_shape(
578                (center_x, center_y),
579                size.min(swatch_size / 2.0),
580                &PointStyle {
581                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
582                    alpha: 1.0,
583                    filled: true,
584                    shape: crate::render::backend::PointShape::Circle,
585                },
586            )?;
587        }
588        Aesthetic::Alpha => {
589            let alpha = scale
590                .map_fraction_to_alpha(pos)
591                .or_else(|| scales.map_alpha(&value))
592                .unwrap_or(1.0);
593            backend.draw_rect(
594                (x, y),
595                (x + swatch_size, y + swatch_size),
596                &RectStyle {
597                    fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
598                    stroke: None,
599                    stroke_width: 0.0,
600                    alpha,
601                    clip: false,
602                    corner_radius: 0.0,
603                },
604            )?;
605        }
606        Aesthetic::Linewidth => {
607            let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
608            backend.draw_line(
609                &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
610                &LineStyle {
611                    color: theme.geom.ink.unwrap_or((50, 50, 50)),
612                    width: width.min(swatch_size),
613                    alpha: 1.0,
614                    linetype: Linetype::Solid,
615                    ..Default::default()
616                },
617            )?;
618        }
619        _ => {}
620    }
621    Ok(())
622}
623
624// ─── B3-2: nrow/ncol/byrow, auto-wrap, "+N more" ───────────────────────────
625
626/// One legend key's grid placement, in the planner's own pixel space
627/// (relative to the legend's own origin — `row` is 0-indexed top row,
628/// `x_offset` is the horizontal pitch from the first column).
629#[derive(Clone, Debug)]
630pub(crate) struct LegendEntry {
631    pub break_idx: usize,
632    pub row: usize,
633    pub x_offset: f64,
634    /// A truncated-with-ellipsis display label (#159), set only when a
635    /// vertical multi-column grid still doesn't fit the canvas after
636    /// reducing its column count as far as it can go: the full label is
637    /// still available (e.g. for a `<title>` tooltip) via the scale's own
638    /// `breaks()[break_idx]`. `None` draws the break's label unchanged —
639    /// the common, byte-identical case.
640    pub label_override: Option<String>,
641}
642
643/// A trailing "+{k} more" marker when the legend doesn't have room for
644/// every key (D7). Not a mark (`draw_text` only — no swatch, no
645/// `data-geom`/class).
646#[derive(Clone, Debug)]
647pub(crate) struct LegendOverflow {
648    pub dropped: usize,
649    pub row: usize,
650    pub x_offset: f64,
651    pub text: String,
652}
653
654/// A non-default legend layout (B3-2): an explicit `nrow`/`ncol` grid, or
655/// an auto-wrapped horizontal legend. `None` from [`plan_legend`] means
656/// "nothing changes" — the caller keeps the 0.21 single-row/single-column
657/// path verbatim, so a plot that doesn't need this is byte-identical.
658#[derive(Clone, Debug)]
659pub(crate) struct LegendPlan {
660    pub rows: usize,
661    pub entries: Vec<LegendEntry>,
662    pub overflow: Option<LegendOverflow>,
663    /// Columns in an explicit grid (always 1 for the horizontal auto-wrap
664    /// path, which never adds columns). Review #158 finding 3: a vertical
665    /// legend with `ncol > 1` needs `(cols − 1) · col_pitch` of extra
666    /// width reserved, the same way extra rows are reserved for height.
667    pub cols: usize,
668    /// The per-column horizontal pitch an explicit grid uses (0 for the
669    /// auto-wrap path, which doesn't have columns in this sense).
670    pub col_pitch: f64,
671}
672
673/// Per-key horizontal pitch (swatch + spacing + label + gap): the larger of
674/// today's crude per-character advance (`draw_discrete_legend_at`'s
675/// `cur_x` formula) and a `text_width`-based measurement, so a label that
676/// ties under both estimates stays on the byte-identical single-row path.
677fn legend_pitch(label: &str, theme: &Theme) -> f64 {
678    let swatch = theme.legend_key_width;
679    let spacing = theme.legend_spacing;
680    let text_size = theme.legend_text.size;
681    let legacy =
682        swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
683    let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
684    legacy.max(measured)
685}
686
687/// Plan a non-default legend layout (B3-2, D7). `avail_width` is the
688/// horizontal room for a horizontal legend's flow-pack / overflow check
689/// (`plot_area.x + plot_area.width - legend_x`); `avail_height` only feeds
690/// the default `max_rows` formula. Returns `None` when nothing changes:
691/// no explicit `nrow`/`ncol`, and (for a horizontal legend) the one-line
692/// layout already fits — or the legend is vertical, where auto-wrap never
693/// applies (D7 is horizontal-only; `nrow`/`ncol` still work on either).
694pub(crate) fn plan_legend(
695    breaks: &[(f64, String)],
696    guide: &GuideLegend,
697    theme: &Theme,
698    avail_width: f64,
699    avail_height: f64,
700    is_horizontal: bool,
701) -> Option<LegendPlan> {
702    let n = breaks.len();
703    if n == 0 {
704        return None;
705    }
706
707    // Explicit grid: ggplot2's `nrow`/`ncol`, column-major unless `byrow`.
708    if guide.nrow.is_some() || guide.ncol.is_some() {
709        let (rows, cols) = match (guide.nrow, guide.ncol) {
710            (Some(nrow), Some(ncol)) => (nrow, ncol),
711            (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
712            (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
713            (None, None) => unreachable!("checked above"),
714        };
715        let rows = rows.max(1);
716        let cols = cols.max(1);
717        let col_pitch = breaks
718            .iter()
719            .map(|(_, l)| legend_pitch(l, theme))
720            .fold(0.0_f64, f64::max);
721        let entries = (0..n)
722            .map(|i| {
723                let (row, col) = if guide.byrow {
724                    (i / cols, i % cols)
725                } else {
726                    (i % rows, i / rows)
727                };
728                LegendEntry {
729                    break_idx: i,
730                    row,
731                    x_offset: col as f64 * col_pitch,
732                    label_override: None,
733                }
734            })
735            .collect();
736        return Some(LegendPlan {
737            rows,
738            entries,
739            overflow: None,
740            cols,
741            col_pitch,
742        });
743    }
744
745    // Auto mode: D7 scopes auto-wrap to horizontal legends only.
746    if !is_horizontal {
747        return None;
748    }
749
750    let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
751    let total_width: f64 = pitches.iter().sum();
752    if total_width <= avail_width.max(0.0) {
753        return None; // fits on one line — byte-identical to 0.21.
754    }
755
756    let item_height = theme.legend_key_height.max(1.0);
757    let row_limit = guide
758        .max_rows
759        .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
760        .max(1);
761
762    let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
763    let mut row = 0usize;
764    let mut cur_x = 0.0_f64;
765    for (i, &p) in pitches.iter().enumerate() {
766        if cur_x > 0.0 && cur_x + p > avail_width {
767            if row + 1 >= row_limit {
768                break; // out of rows — the rest overflow into "+N more".
769            }
770            row += 1;
771            cur_x = 0.0;
772        }
773        entries.push(LegendEntry {
774            break_idx: i,
775            row,
776            x_offset: cur_x,
777            label_override: None,
778        });
779        cur_x += p;
780    }
781
782    if entries.len() >= n {
783        return Some(LegendPlan {
784            rows: row + 1,
785            entries,
786            overflow: None,
787            cols: 1,
788            col_pitch: 0.0,
789        });
790    }
791
792    // Overflow: make room for "+{k} more" on the last row, dropping
793    // entries from its tail until it fits (or the row is empty).
794    let mut dropped = n - entries.len();
795    loop {
796        let text = format!("+{dropped} more");
797        let more_pitch = legend_pitch(&text, theme);
798        let row_count = entries.iter().filter(|e| e.row == row).count();
799        let last_x = entries
800            .iter()
801            .filter(|e| e.row == row)
802            .map(|e| e.x_offset + pitches[e.break_idx])
803            .fold(0.0_f64, f64::max);
804        if last_x + more_pitch <= avail_width || row_count == 0 {
805            return Some(LegendPlan {
806                rows: row + 1,
807                entries,
808                overflow: Some(LegendOverflow {
809                    dropped,
810                    row,
811                    x_offset: last_x,
812                    text,
813                }),
814                cols: 1,
815                col_pitch: 0.0,
816            });
817        }
818        entries.pop();
819        dropped += 1;
820    }
821}
822
823/// How many of a plot's legend scales are discrete (the ones B3-2's
824/// wrap/grid applies to) — used to split the available horizontal room
825/// among them when several sit side by side (sipemu/ggplot-rs#155).
826fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
827    legend_scales
828        .iter()
829        .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
830        .count()
831        .max(1)
832}
833
834/// How many of a plot's legend scales are continuous (a colourbar, or a
835/// sampled continuous size/alpha legend) — each reserves its own fixed
836/// width share of a horizontal row (#170 / review #167 finding 7: a
837/// bottom legend mixing a colourbar with a discrete guide ignored the
838/// colourbar's share, so the discrete guide's auto-wrap/grid was planned
839/// against the *whole* row and could still run off the right edge).
840fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
841    legend_scales
842        .iter()
843        .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
844        .count()
845}
846
847/// The fixed horizontal width one continuous guide reserves in a
848/// horizontal row — the same per-guide step `draw_legend_planned` already
849/// advances `offset_x` by once it has drawn one (see its two
850/// `is_horizontal` arms for a continuous scale).
851fn continuous_guide_width(theme: &Theme) -> f64 {
852    theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
853}
854
855/// Plan every discrete legend scale's B3-2 layout (explicit `nrow`/`ncol`
856/// grid, or auto-wrap) independently (sipemu/ggplot-rs#155): previously
857/// this only ran for the plot's single discrete guide, leaving a second
858/// simultaneous discrete guide (e.g. both `colour` and `shape` mapped to
859/// discrete variables) always on the unwrapped 0.21 path. When several
860/// discrete guides sit side by side (`is_horizontal`), the available
861/// width is split evenly among them first, so each one wraps against its
862/// own share rather than the whole row (which would let it claim more
863/// room than its neighbours leave). Guides that stack vertically instead
864/// each get the full `avail_width`, since they don't compete for it.
865/// Returns one entry per discrete guide that needs anything beyond the
866/// single-row/single-column path — a guide absent from the map is
867/// byte-identical to 0.21/0.22.
868#[allow(clippy::too_many_arguments)]
869pub(crate) fn plan_all_legends(
870    scales: &ScaleSet,
871    guide: &GuideLegend,
872    theme: &Theme,
873    suppressed: &std::collections::HashSet<Aesthetic>,
874    avail_width: f64,
875    avail_height: f64,
876    is_horizontal: bool,
877    warnings: &mut Vec<String>,
878) -> std::collections::HashMap<Aesthetic, LegendPlan> {
879    let legend_scales = legend_scales_for(scales, suppressed);
880    let n_discrete = discrete_guide_count(scales, &legend_scales);
881    let per_guide_width = if is_horizontal {
882        // #170: leave the discrete guides only the width left over after
883        // every continuous guide's own fixed share (a colourbar sitting
884        // in the same row) — previously `avail_width` was split among
885        // the discrete guides alone, so a colourbar plus a discrete
886        // guide could together still overflow the row.
887        let n_continuous = continuous_guide_count(scales, &legend_scales);
888        let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
889        ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
890    } else {
891        avail_width
892    };
893
894    let mut plans = std::collections::HashMap::new();
895    for aes in &legend_scales {
896        let Some(scale) = scales.get(aes) else {
897            continue;
898        };
899        if !scale.is_discrete() {
900            continue;
901        }
902        let breaks = scale.breaks();
903        let validated_guide = guide.validated(breaks.len(), warnings);
904        if let Some(plan) = plan_legend(
905            &breaks,
906            &validated_guide,
907            theme,
908            per_guide_width,
909            avail_height,
910            is_horizontal,
911        ) {
912            plans.insert(aes.clone(), plan);
913        }
914    }
915    plans
916}
917
918/// Draw all legends for the plot, honouring each discrete guide's B3-2
919/// `plan_legend` layout (explicit `nrow`/`ncol`, or an overflowing
920/// horizontal legend's auto-wrap) when it has one — several simultaneous
921/// discrete guides each get their own independent plan
922/// (sipemu/ggplot-rs#155). A guide missing from `legend_plans` falls back
923/// to the unchanged [`draw_legend`] path — same calls, same order — which
924/// keeps this byte-identical to 0.21/0.22 for every plot nothing in
925/// `legend_plans` touches. Used by `renderer.rs` (single and faceted) and
926/// `compose::capture_legend` (PlotGrid-collected legends).
927#[allow(clippy::too_many_arguments)]
928pub(crate) fn draw_legend_planned(
929    scales: &ScaleSet,
930    theme: &Theme,
931    plot_area: &Rect,
932    // Review #167 finding 1: the width share each discrete guide was
933    // *planned* against (the same `avail_width` passed to
934    // `plan_all_legends`), not `plot_area.width`. For a single plot these
935    // are the same value in practice, but a PlotGrid's collected legend
936    // (`compose::capture_legend`) measures itself against a fake
937    // zero-width panel so its origin lands at (0, 0) — stepping by
938    // `plot_area.width / n` there always advanced by zero, so the second
939    // discrete guide (e.g. `shape` after `colour`) was drawn directly on
940    // top of the first instead of beside it.
941    legend_avail_width: f64,
942    backend: &mut dyn DrawBackend,
943    guide: &GuideLegend,
944    suppressed: &std::collections::HashSet<Aesthetic>,
945    // Review #158 finding 2: each discrete guide's plan, computed exactly
946    // once in `layout_built` (`BuiltPlot::legend_plans`) — drawn verbatim
947    // here rather than recomputed against this (possibly shorter, post-
948    // row-reservation) `plot_area`, so the drawn "+N more" always matches
949    // the warning that plan already produced.
950    legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
951) -> Result<(), RenderError> {
952    if matches!(theme.legend_position, LegendPosition::None) {
953        return Ok(());
954    }
955
956    let legend_scales = legend_scales_for(scales, suppressed);
957    if legend_scales.is_empty() {
958        return Ok(());
959    }
960
961    let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
962    if let Some(dir) = theme.legend_direction {
963        is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
964    }
965    let n_discrete = discrete_guide_count(scales, &legend_scales);
966    // #170: match `plan_all_legends`'s share — a continuous guide (e.g. a
967    // colourbar) sharing this row has already claimed its own fixed
968    // width, so step the discrete guides by what's left, not the whole
969    // `legend_avail_width`.
970    let n_continuous = continuous_guide_count(scales, &legend_scales);
971    let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
972    let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
973
974    let mut offset_y = legend_y;
975    let mut offset_x = legend_x;
976
977    for aes in &legend_scales {
978        let scale = scales.get(aes).unwrap();
979        if !scale.is_discrete() {
980            // Continuous legends (colorbar, or size/alpha sampled breaks)
981            // aren't in scope for B3-2 — unchanged.
982            if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
983                let height =
984                    draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
985                if is_horizontal {
986                    offset_x += theme.legend_key_width
987                        + theme.legend_text.size * 6.0
988                        + theme.legend_spacing * 2.0;
989                } else {
990                    offset_y += height + theme.legend_spacing * 2.0;
991                }
992            } else {
993                let height = draw_discrete_legend_at(
994                    scales,
995                    aes,
996                    scale,
997                    theme,
998                    offset_x,
999                    offset_y,
1000                    backend,
1001                    guide,
1002                    is_horizontal,
1003                )?;
1004                if is_horizontal {
1005                    offset_x += theme.legend_key_width
1006                        + theme.legend_text.size * 6.0
1007                        + theme.legend_spacing * 2.0;
1008                } else {
1009                    offset_y += height + theme.legend_spacing * 2.0;
1010                }
1011            }
1012            continue;
1013        }
1014
1015        match legend_plans.get(aes) {
1016            None => {
1017                if is_horizontal {
1018                    let width = draw_discrete_legend_at(
1019                        scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1020                    )?;
1021                    offset_x += width + theme.legend_spacing * 2.0;
1022                } else {
1023                    let height = draw_discrete_legend_at(
1024                        scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1025                    )?;
1026                    offset_y += height + theme.legend_spacing * 2.0;
1027                }
1028            }
1029            Some(plan) => {
1030                let height = draw_discrete_legend_grid(
1031                    scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1032                )?;
1033                if is_horizontal {
1034                    // Each discrete guide gets an equal share of the row
1035                    // (sipemu/ggplot-rs#155) rather than the whole row,
1036                    // which only held for exactly one discrete guide.
1037                    offset_x += per_guide_width;
1038                } else {
1039                    offset_y += height + theme.legend_spacing * 2.0;
1040                }
1041            }
1042        }
1043    }
1044
1045    Ok(())
1046}
1047
1048/// Draw a discrete legend using a [`LegendPlan`] (explicit grid or
1049/// auto-wrap). Returns the total height used (including any "+N more"
1050/// row).
1051#[allow(clippy::too_many_arguments)]
1052fn draw_discrete_legend_grid(
1053    scales: &ScaleSet,
1054    aes: &Aesthetic,
1055    scale: &dyn crate::scale::Scale,
1056    theme: &Theme,
1057    legend_x: f64,
1058    legend_y: f64,
1059    backend: &mut dyn DrawBackend,
1060    guide: &GuideLegend,
1061    plan: &LegendPlan,
1062) -> Result<f64, RenderError> {
1063    let mut breaks = scale.breaks();
1064    if breaks.is_empty() {
1065        return Ok(0.0);
1066    }
1067    if guide.reverse {
1068        breaks.reverse();
1069    }
1070
1071    let swatch_size = theme.legend_key_width;
1072    let item_height = theme.legend_key_height;
1073
1074    let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1075    let legend_family = if theme.legend_title.family.is_empty() {
1076        None
1077    } else {
1078        Some(theme.legend_title.family.clone())
1079    };
1080    let title_offset = if !title.is_empty() {
1081        backend.draw_text(
1082            title,
1083            (legend_x, legend_y),
1084            &TextStyle {
1085                color: theme.legend_title.color,
1086                size: theme.legend_title.size,
1087                anchor: TextAnchor::Start,
1088                angle: 0.0,
1089                family: legend_family,
1090                face: theme.legend_title.face,
1091            },
1092        )?;
1093        theme.legend_title.size + 4.0
1094    } else {
1095        0.0
1096    };
1097    let items_y = legend_y + title_offset;
1098
1099    for entry in &plan.entries {
1100        let (pos, label) = &breaks[entry.break_idx];
1101        let x = legend_x + entry.x_offset;
1102        let y = items_y + entry.row as f64 * item_height;
1103        let center_x = x + swatch_size / 2.0;
1104        let center_y = y + swatch_size / 2.0;
1105
1106        if theme.legend_key.visible {
1107            if let Some(fill) = theme.legend_key.fill {
1108                backend.draw_rect(
1109                    (x, y),
1110                    (x + swatch_size, y + swatch_size),
1111                    &RectStyle {
1112                        fill: Some(fill),
1113                        stroke: theme.legend_key.color,
1114                        stroke_width: theme.legend_key.width,
1115                        alpha: 1.0,
1116                        clip: false,
1117                        corner_radius: 0.0,
1118                    },
1119                )?;
1120            }
1121        }
1122
1123        draw_legend_swatch(
1124            backend,
1125            scales,
1126            aes,
1127            scale,
1128            theme,
1129            label,
1130            *pos,
1131            x,
1132            y,
1133            center_x,
1134            center_y,
1135            swatch_size,
1136        )?;
1137
1138        let label_family = if theme.legend_text.family.is_empty() {
1139            None
1140        } else {
1141            Some(theme.legend_text.family.clone())
1142        };
1143        // #159: a label truncated to fit a vertical multi-column grid
1144        // still shows its full text via a `<title>` tooltip (backends
1145        // without tooltip support just render the truncated text).
1146        let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1147        backend.draw_text_with_title(
1148            display,
1149            (x + swatch_size + theme.legend_spacing, center_y),
1150            &TextStyle {
1151                color: theme.legend_text.color,
1152                size: theme.legend_text.size,
1153                anchor: TextAnchor::Start,
1154                angle: 0.0,
1155                family: label_family,
1156                face: theme.legend_text.face,
1157            },
1158            entry.label_override.as_deref().map(|_| label.as_str()),
1159        )?;
1160    }
1161
1162    // The overflow marker is text only — not a mark, no swatch.
1163    if let Some(ov) = &plan.overflow {
1164        let x = legend_x + ov.x_offset;
1165        let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1166        let label_family = if theme.legend_text.family.is_empty() {
1167            None
1168        } else {
1169            Some(theme.legend_text.family.clone())
1170        };
1171        backend.draw_text(
1172            &ov.text,
1173            (x, y),
1174            &TextStyle {
1175                color: theme.legend_text.color,
1176                size: theme.legend_text.size,
1177                anchor: TextAnchor::Start,
1178                angle: 0.0,
1179                family: label_family,
1180                face: theme.legend_text.face,
1181            },
1182        )?;
1183    }
1184
1185    Ok(title_offset + plan.rows as f64 * item_height)
1186}