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