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