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
11const 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
22pub(crate) fn is_legend_aesthetic(a: &Aesthetic) -> bool {
31 LEGEND_AESTHETICS.contains(a)
32}
33
34pub(crate) fn legend_scales_for(
40 scales: &ScaleSet,
41 suppressed: &std::collections::HashSet<Aesthetic>,
42) -> Vec<Aesthetic> {
43 let mut legend_scales: Vec<Aesthetic> = Vec::new();
44 for aes in LEGEND_AESTHETICS {
45 if suppressed.contains(aes) {
46 continue;
47 }
48 if let Some(scale) = scales.get(aes) {
49 if !scale.breaks().is_empty() {
50 if *aes == Aesthetic::Fill && legend_scales.contains(&Aesthetic::Color) {
51 continue;
52 }
53 legend_scales.push(aes.clone());
54 }
55 }
56 }
57 legend_scales
58}
59
60#[deprecated(
72 since = "0.23.0",
73 note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
74 sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
75 by GGPlot's render methods. Will be removed in a later minor release."
76)]
77pub fn draw_legend(
78 scales: &ScaleSet,
79 theme: &Theme,
80 plot_area: &Rect,
81 backend: &mut dyn DrawBackend,
82 guide: &GuideLegend,
83 suppressed: &std::collections::HashSet<Aesthetic>,
84) -> Result<(), RenderError> {
85 if matches!(theme.legend_position, LegendPosition::None) {
86 return Ok(());
87 }
88
89 let mut legend_scales: Vec<&Aesthetic> = Vec::new();
91 for aes in LEGEND_AESTHETICS {
92 if suppressed.contains(aes) {
94 continue;
95 }
96 if let Some(scale) = scales.get(aes) {
97 if !scale.breaks().is_empty() {
98 if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
100 continue;
101 }
102 legend_scales.push(aes);
103 }
104 }
105 }
106
107 if legend_scales.is_empty() {
108 return Ok(());
109 }
110
111 let (legend_x, legend_y, mut is_horizontal) =
119 legend_position(theme, plot_area, 0, (0.0, 0.0), plot_area);
120 if let Some(dir) = theme.legend_direction {
122 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
123 }
124
125 let mut offset_y = legend_y;
126 let mut offset_x = legend_x;
127
128 for aes in &legend_scales {
129 let scale = scales.get(aes).unwrap();
130
131 if scale.is_discrete() {
132 if is_horizontal {
133 let width = draw_discrete_legend_at(
134 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
135 )?;
136 offset_x += width + theme.legend_spacing * 2.0;
137 } else {
138 let height = draw_discrete_legend_at(
139 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
140 )?;
141 offset_y += height + theme.legend_spacing * 2.0;
142 }
143 } else {
144 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
146 let height =
147 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
148 if is_horizontal {
149 offset_x += theme.legend_key_width
150 + theme.legend_text.size * 6.0
151 + theme.legend_spacing * 2.0;
152 } else {
153 offset_y += height + theme.legend_spacing * 2.0;
154 }
155 } else {
156 let height = draw_discrete_legend_at(
158 scales,
159 aes,
160 scale,
161 theme,
162 offset_x,
163 offset_y,
164 backend,
165 guide,
166 is_horizontal,
167 )?;
168 if is_horizontal {
169 offset_x += theme.legend_key_width
170 + theme.legend_text.size * 6.0
171 + theme.legend_spacing * 2.0;
172 } else {
173 offset_y += height + theme.legend_spacing * 2.0;
174 }
175 }
176 }
177 }
178
179 Ok(())
180}
181
182fn legend_rows_beyond_first(
189 is_horizontal: bool,
190 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
191) -> usize {
192 if is_horizontal {
193 legend_plans
194 .values()
195 .map(|p| p.rows.saturating_sub(1))
196 .max()
197 .unwrap_or(0)
198 } else {
199 legend_plans
200 .values()
201 .map(|p| p.rows.saturating_sub(1))
202 .sum()
203 }
204}
205
206pub(crate) fn legend_band_height(theme: &Theme, legend_extra_rows: usize) -> f64 {
212 theme.legend_margin.top
213 + (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
214 + theme.legend_margin.bottom
215}
216
217pub(crate) fn top_legend_lift(
224 theme: &Theme,
225 scales: &ScaleSet,
226 suppressed: &std::collections::HashSet<Aesthetic>,
227 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
228) -> f64 {
229 if !matches!(theme.legend_position, LegendPosition::Top) {
230 return 0.0;
231 }
232 if legend_scales_for(scales, suppressed).is_empty() {
233 return 0.0;
234 }
235 let is_horizontal = match theme.legend_direction {
236 Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
237 None => true, };
239 legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans))
240}
241
242fn estimate_legend_box(
255 scales: &ScaleSet,
256 legend_scales: &[Aesthetic],
257 theme: &Theme,
258 guide: &GuideLegend,
259 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
260) -> (f64, f64) {
261 let mut total_h = 0.0_f64;
262 let mut max_w = 0.0_f64;
263 for aes in legend_scales {
264 let Some(scale) = scales.get(aes) else {
265 continue;
266 };
267 let breaks = scale.breaks();
268 if breaks.is_empty() {
269 continue;
270 }
271 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
272 let title_offset = if !title.is_empty() {
273 theme.legend_title.size + 4.0
274 } else {
275 0.0
276 };
277 let diameter = size_key_diameter(aes, scales, scale, &breaks);
280 let key_width = theme.legend_key_width.max(diameter);
281 let key_height = theme.legend_key_height.max(key_width);
282 let single_col_width = key_width + theme.legend_spacing + theme.legend_text.size * 6.0;
283 let is_continuous_bar =
284 matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
285 let (w, h) = if is_continuous_bar {
286 (single_col_width, title_offset + key_height * 8.0)
287 } else if let Some(plan) = legend_plans.get(aes) {
288 let w = if plan.col_pitch > 0.0 {
289 plan.cols as f64 * plan.col_pitch
290 } else {
291 single_col_width
292 };
293 (w, title_offset + plan.rows as f64 * key_height)
294 } else {
295 (
296 single_col_width,
297 title_offset + breaks.len() as f64 * key_height,
298 )
299 };
300 total_h += h;
301 max_w = max_w.max(w);
302 }
303 (max_w, total_h)
304}
305
306fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
312 if size >= hi - lo {
313 lo
314 } else {
315 pos.clamp(lo, hi - size)
316 }
317}
318
319pub(crate) fn legend_position(
354 theme: &Theme,
355 plot_area: &Rect,
356 legend_extra_rows: usize,
357 legend_size_hint: (f64, f64),
358 canvas: &Rect,
359) -> (f64, f64, bool) {
360 match theme.legend_position {
361 LegendPosition::Right => (
362 plot_area.x + plot_area.width + theme.legend_margin.left,
363 plot_area.y + theme.legend_margin.top,
364 false,
365 ),
366 LegendPosition::Left => (
367 theme.legend_margin.left,
368 plot_area.y + theme.legend_margin.top,
369 false,
370 ),
371 LegendPosition::Top => (
372 plot_area.x + theme.legend_margin.left,
373 plot_area.y - legend_band_height(theme, legend_extra_rows) + theme.legend_margin.top,
374 true,
375 ),
376 LegendPosition::Bottom => (
377 plot_area.x + theme.legend_margin.left,
378 plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
379 true,
380 ),
381 LegendPosition::None => (0.0, 0.0, false),
382 LegendPosition::Inside(fx, fy) => {
383 let (legend_w, legend_h) = legend_size_hint;
384 let raw_x = plot_area.x + fx * (plot_area.width - legend_w);
385 let raw_y = plot_area.y + (1.0 - fy) * (plot_area.height - legend_h);
388 let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
389 let y = clamp_span(raw_y, legend_h, canvas.y, canvas.y + canvas.height);
390 (x, y, false)
391 }
392 }
393}
394
395pub(crate) fn size_key_diameter(
405 aes: &Aesthetic,
406 scales: &ScaleSet,
407 scale: &dyn crate::scale::Scale,
408 breaks: &[(f64, String)],
409) -> f64 {
410 if *aes != Aesthetic::Size {
411 return 0.0;
412 }
413 let max_r = breaks
414 .iter()
415 .map(|(pos, label)| {
416 scale
417 .map_fraction_to_size(*pos)
418 .or_else(|| scales.map_size(&Value::Str(label.clone())))
419 .unwrap_or(3.0)
420 })
421 .fold(0.0_f64, f64::max);
422 max_r * 2.0
423}
424
425#[allow(clippy::too_many_arguments)]
427fn draw_discrete_legend_at(
428 scales: &ScaleSet,
429 aes: &Aesthetic,
430 scale: &dyn crate::scale::Scale,
431 theme: &Theme,
432 legend_x: f64,
433 legend_y: f64,
434 backend: &mut dyn DrawBackend,
435 guide: &GuideLegend,
436 is_horizontal: bool,
437) -> Result<f64, RenderError> {
438 let mut breaks = scale.breaks();
439 if breaks.is_empty() {
440 return Ok(0.0);
441 }
442
443 if guide.reverse {
445 breaks.reverse();
446 }
447
448 let diameter = size_key_diameter(aes, scales, scale, &breaks);
453 let swatch_size = theme.legend_key_width.max(diameter);
454 let item_height = theme.legend_key_height.max(swatch_size);
455
456 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
458 let legend_family = if theme.legend_title.family.is_empty() {
459 None
460 } else {
461 Some(theme.legend_title.family.clone())
462 };
463 let title_offset = if !title.is_empty() {
464 backend.draw_text(
465 title,
466 (legend_x, legend_y),
467 &TextStyle {
468 color: theme.legend_title.color,
469 size: theme.legend_title.size,
470 anchor: TextAnchor::Start,
471 angle: 0.0,
472 family: legend_family,
473 face: theme.legend_title.face,
474 bg_colour: None,
475 bg_r: 0.0,
476 },
477 )?;
478 theme.legend_title.size + 4.0
479 } else {
480 0.0
481 };
482
483 let items_y = legend_y + title_offset;
484
485 if theme.legend_background.visible {
487 let total_height = breaks.len() as f64 * item_height;
488 let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
489 if let Some(fill) = theme.legend_background.fill {
490 backend.draw_rect(
491 (legend_x - 2.0, items_y - 2.0),
492 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
493 &RectStyle {
494 fill: Some(fill),
495 stroke: theme.legend_background.color,
496 stroke_width: theme.legend_background.width,
497 alpha: 1.0,
498 clip: false,
499 corner_radius: 0.0,
500 },
501 )?;
502 }
503 }
504
505 let mut cur_x = legend_x;
507 for (i, (pos, label)) in breaks.iter().enumerate() {
508 let x = if is_horizontal { cur_x } else { legend_x };
509 let y = if is_horizontal {
510 items_y
511 } else {
512 items_y + i as f64 * item_height
513 };
514 let center_x = x + swatch_size / 2.0;
515 let center_y = y + swatch_size / 2.0;
516
517 if theme.legend_key.visible {
519 if let Some(fill) = theme.legend_key.fill {
520 backend.draw_rect(
521 (x, y),
522 (x + swatch_size, y + swatch_size),
523 &RectStyle {
524 fill: Some(fill),
525 stroke: theme.legend_key.color,
526 stroke_width: theme.legend_key.width,
527 alpha: 1.0,
528 clip: false,
529 corner_radius: 0.0,
530 },
531 )?;
532 }
533 }
534
535 draw_legend_swatch(
537 backend,
538 scales,
539 aes,
540 scale,
541 theme,
542 label,
543 *pos,
544 x,
545 y,
546 center_x,
547 center_y,
548 swatch_size,
549 )?;
550
551 let label_family = if theme.legend_text.family.is_empty() {
553 None
554 } else {
555 Some(theme.legend_text.family.clone())
556 };
557 backend.draw_text(
558 label,
559 (x + swatch_size + theme.legend_spacing, center_y),
560 &TextStyle {
561 color: theme.legend_text.color,
562 size: theme.legend_text.size,
563 anchor: TextAnchor::Start,
564 angle: 0.0,
565 family: label_family,
566 face: theme.legend_text.face,
567 bg_colour: None,
568 bg_r: 0.0,
569 },
570 )?;
571 if is_horizontal {
572 cur_x = x
574 + swatch_size
575 + theme.legend_spacing
576 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
577 + theme.legend_spacing * 3.0;
578 }
579 }
580
581 if is_horizontal {
582 Ok(cur_x - legend_x) } else {
584 Ok(title_offset + breaks.len() as f64 * item_height)
585 }
586}
587
588fn draw_continuous_legend_at(
590 scale: &dyn crate::scale::Scale,
591 theme: &Theme,
592 legend_x: f64,
593 legend_y: f64,
594 backend: &mut dyn DrawBackend,
595 guide: &GuideLegend,
596) -> Result<f64, RenderError> {
597 let breaks = scale.breaks();
598 if breaks.is_empty() {
599 return Ok(0.0);
600 }
601
602 let bar_width = theme.legend_key_width;
603 let bar_height = theme.legend_key_height * 8.0;
604
605 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
607 let cont_family = if theme.legend_title.family.is_empty() {
608 None
609 } else {
610 Some(theme.legend_title.family.clone())
611 };
612 let title_offset = if !title.is_empty() {
613 backend.draw_text(
614 title,
615 (legend_x, legend_y),
616 &TextStyle {
617 color: theme.legend_title.color,
618 size: theme.legend_title.size,
619 anchor: TextAnchor::Start,
620 angle: 0.0,
621 family: cont_family,
622 face: theme.legend_title.face,
623 bg_colour: None,
624 bg_r: 0.0,
625 },
626 )?;
627 theme.legend_title.size + 4.0
628 } else {
629 0.0
630 };
631
632 let bar_top = legend_y + title_offset;
633
634 if theme.legend_background.visible {
636 let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
637 if let Some(fill) = theme.legend_background.fill {
638 backend.draw_rect(
639 (legend_x - 2.0, bar_top - 2.0),
640 (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
641 &RectStyle {
642 fill: Some(fill),
643 stroke: theme.legend_background.color,
644 stroke_width: theme.legend_background.width,
645 alpha: 1.0,
646 clip: false,
647 corner_radius: 0.0,
648 },
649 )?;
650 }
651 }
652
653 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
656 let n_slices = 50;
657 let slice_height = bar_height / n_slices as f64;
658 for i in 0..n_slices {
659 let t = 1.0 - i as f64 / n_slices as f64;
660 let data_val = data_min + t * (data_max - data_min);
661 let color = scale
662 .map_to_color(&Value::Float(data_val))
663 .unwrap_or((127, 127, 127));
664 let sy = bar_top + i as f64 * slice_height;
665 backend.draw_rect(
666 (legend_x, sy),
667 (legend_x + bar_width, sy + slice_height + 0.5),
668 &RectStyle {
669 fill: Some(color),
670 stroke: None,
671 stroke_width: 0.0,
672 alpha: 1.0,
673 clip: false,
674 corner_radius: 0.0,
675 },
676 )?;
677 }
678
679 let border = theme.legend_bar_border_line();
682 let border_style = LineStyle {
683 color: border.color,
684 width: border.width,
685 alpha: 1.0,
686 linetype: border.linetype,
687 ..Default::default()
688 };
689 if border.visible {
690 backend.draw_line(
691 &[
692 (legend_x, bar_top),
693 (legend_x + bar_width, bar_top),
694 (legend_x + bar_width, bar_top + bar_height),
695 (legend_x, bar_top + bar_height),
696 (legend_x, bar_top),
697 ],
698 &border_style,
699 )?;
700 }
701
702 let tick_len = 3.0;
704 for (pos, label) in &breaks {
705 let tick_y = bar_top + bar_height * (1.0 - pos);
706 backend.draw_line(
707 &[
708 (legend_x + bar_width, tick_y),
709 (legend_x + bar_width + tick_len, tick_y),
710 ],
711 &border_style,
712 )?;
713 let tick_family = if theme.legend_text.family.is_empty() {
714 None
715 } else {
716 Some(theme.legend_text.family.clone())
717 };
718 backend.draw_text(
719 label,
720 (
721 legend_x + bar_width + tick_len + theme.legend_spacing,
722 tick_y,
723 ),
724 &TextStyle {
725 color: theme.legend_text.color,
726 size: theme.legend_text.size,
727 anchor: TextAnchor::Start,
728 angle: 0.0,
729 family: tick_family,
730 face: theme.legend_text.face,
731 bg_colour: None,
732 bg_r: 0.0,
733 },
734 )?;
735 }
736
737 Ok(title_offset + bar_height)
738}
739
740#[allow(clippy::too_many_arguments)]
762fn draw_legend_swatch(
763 backend: &mut dyn DrawBackend,
764 scales: &ScaleSet,
765 aes: &Aesthetic,
766 scale: &dyn crate::scale::Scale,
767 theme: &Theme,
768 label: &str,
769 pos: f64,
770 x: f64,
771 y: f64,
772 center_x: f64,
773 center_y: f64,
774 swatch_size: f64,
775) -> Result<(), RenderError> {
776 let value = Value::Str(label.to_string());
777 match aes {
778 Aesthetic::Color | Aesthetic::Fill => {
779 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
780 let style = RectStyle {
781 fill: Some(color),
782 stroke: None,
783 stroke_width: 0.0,
784 alpha: 1.0,
785 clip: false,
786 corner_radius: 0.0,
787 };
788 if scale.is_na_break(label) {
791 if let Some(p) = scale.na_fill_pattern() {
792 return backend.draw_rect_pattern(
793 (x, y),
794 (x + swatch_size, y + swatch_size),
795 &p,
796 &style,
797 );
798 }
799 }
800 backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
801 }
802 Aesthetic::Shape => {
803 let shape = scales
804 .map_shape(&value)
805 .unwrap_or(crate::render::backend::PointShape::Circle);
806 backend.draw_shape(
807 (center_x, center_y),
808 swatch_size / 3.0,
809 &PointStyle {
810 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
811 alpha: 1.0,
812 filled: true,
813 shape,
814 },
815 )?;
816 }
817 Aesthetic::Linetype => {
818 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
819 backend.draw_line(
820 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
821 &LineStyle {
822 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
823 width: 1.5,
824 alpha: 1.0,
825 linetype: lt,
826 ..Default::default()
827 },
828 )?;
829 }
830 Aesthetic::Size => {
831 let size = scale
832 .map_fraction_to_size(pos)
833 .or_else(|| scales.map_size(&value))
834 .unwrap_or(3.0);
835 backend.draw_shape(
836 (center_x, center_y),
837 size.min(swatch_size / 2.0),
838 &PointStyle {
839 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
840 alpha: 1.0,
841 filled: true,
842 shape: crate::render::backend::PointShape::Circle,
843 },
844 )?;
845 }
846 Aesthetic::Alpha => {
847 let alpha = scale
848 .map_fraction_to_alpha(pos)
849 .or_else(|| scales.map_alpha(&value))
850 .unwrap_or(1.0);
851 backend.draw_rect(
852 (x, y),
853 (x + swatch_size, y + swatch_size),
854 &RectStyle {
855 fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
856 stroke: None,
857 stroke_width: 0.0,
858 alpha,
859 clip: false,
860 corner_radius: 0.0,
861 },
862 )?;
863 }
864 Aesthetic::Linewidth => {
865 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
866 backend.draw_line(
867 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
868 &LineStyle {
869 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
870 width: width.min(swatch_size),
871 alpha: 1.0,
872 linetype: Linetype::Solid,
873 ..Default::default()
874 },
875 )?;
876 }
877 _ => {}
878 }
879 Ok(())
880}
881
882#[derive(Clone, Debug)]
888pub(crate) struct LegendEntry {
889 pub break_idx: usize,
890 pub row: usize,
891 pub x_offset: f64,
892 pub label_override: Option<String>,
899}
900
901#[derive(Clone, Debug)]
905pub(crate) struct LegendOverflow {
906 pub dropped: usize,
907 pub row: usize,
908 pub x_offset: f64,
909 pub text: String,
910}
911
912#[derive(Clone, Debug)]
917pub(crate) struct LegendPlan {
918 pub rows: usize,
919 pub entries: Vec<LegendEntry>,
920 pub overflow: Option<LegendOverflow>,
921 pub cols: usize,
926 pub col_pitch: f64,
929}
930
931fn legend_pitch(label: &str, theme: &Theme) -> f64 {
936 let swatch = theme.legend_key_width;
937 let spacing = theme.legend_spacing;
938 let text_size = theme.legend_text.size;
939 let legacy =
940 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
941 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
942 legacy.max(measured)
943}
944
945pub(crate) fn plan_legend(
953 breaks: &[(f64, String)],
954 guide: &GuideLegend,
955 theme: &Theme,
956 avail_width: f64,
957 avail_height: f64,
958 is_horizontal: bool,
959) -> Option<LegendPlan> {
960 let n = breaks.len();
961 if n == 0 {
962 return None;
963 }
964
965 if guide.nrow.is_some() || guide.ncol.is_some() {
967 let (rows, cols) = match (guide.nrow, guide.ncol) {
968 (Some(nrow), Some(ncol)) => (nrow, ncol),
969 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
970 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
971 (None, None) => unreachable!("checked above"),
972 };
973 let rows = rows.max(1);
974 let cols = cols.max(1);
975 let col_pitch = breaks
976 .iter()
977 .map(|(_, l)| legend_pitch(l, theme))
978 .fold(0.0_f64, f64::max);
979 let entries = (0..n)
980 .map(|i| {
981 let (row, col) = if guide.byrow {
982 (i / cols, i % cols)
983 } else {
984 (i % rows, i / rows)
985 };
986 LegendEntry {
987 break_idx: i,
988 row,
989 x_offset: col as f64 * col_pitch,
990 label_override: None,
991 }
992 })
993 .collect();
994 return Some(LegendPlan {
995 rows,
996 entries,
997 overflow: None,
998 cols,
999 col_pitch,
1000 });
1001 }
1002
1003 if !is_horizontal {
1005 return None;
1006 }
1007
1008 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1009 let total_width: f64 = pitches.iter().sum();
1010 if total_width <= avail_width.max(0.0) {
1011 return None; }
1013
1014 let item_height = theme.legend_key_height.max(1.0);
1015 let row_limit = guide
1016 .max_rows
1017 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1018 .max(1);
1019
1020 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1021 let mut row = 0usize;
1022 let mut cur_x = 0.0_f64;
1023 for (i, &p) in pitches.iter().enumerate() {
1024 if cur_x > 0.0 && cur_x + p > avail_width {
1025 if row + 1 >= row_limit {
1026 break; }
1028 row += 1;
1029 cur_x = 0.0;
1030 }
1031 entries.push(LegendEntry {
1032 break_idx: i,
1033 row,
1034 x_offset: cur_x,
1035 label_override: None,
1036 });
1037 cur_x += p;
1038 }
1039
1040 if entries.len() >= n {
1041 return Some(LegendPlan {
1042 rows: row + 1,
1043 entries,
1044 overflow: None,
1045 cols: 1,
1046 col_pitch: 0.0,
1047 });
1048 }
1049
1050 let mut dropped = n - entries.len();
1053 loop {
1054 let text = format!("+{dropped} more");
1055 let more_pitch = legend_pitch(&text, theme);
1056 let row_count = entries.iter().filter(|e| e.row == row).count();
1057 let last_x = entries
1058 .iter()
1059 .filter(|e| e.row == row)
1060 .map(|e| e.x_offset + pitches[e.break_idx])
1061 .fold(0.0_f64, f64::max);
1062 if last_x + more_pitch <= avail_width || row_count == 0 {
1063 return Some(LegendPlan {
1064 rows: row + 1,
1065 entries,
1066 overflow: Some(LegendOverflow {
1067 dropped,
1068 row,
1069 x_offset: last_x,
1070 text,
1071 }),
1072 cols: 1,
1073 col_pitch: 0.0,
1074 });
1075 }
1076 entries.pop();
1077 dropped += 1;
1078 }
1079}
1080
1081fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1085 legend_scales
1086 .iter()
1087 .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
1088 .count()
1089 .max(1)
1090}
1091
1092fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1099 legend_scales
1100 .iter()
1101 .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
1102 .count()
1103}
1104
1105fn continuous_guide_width(theme: &Theme) -> f64 {
1110 theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
1111}
1112
1113#[allow(clippy::too_many_arguments)]
1127pub(crate) fn plan_all_legends(
1128 scales: &ScaleSet,
1129 guide: &GuideLegend,
1130 theme: &Theme,
1131 suppressed: &std::collections::HashSet<Aesthetic>,
1132 avail_width: f64,
1133 avail_height: f64,
1134 is_horizontal: bool,
1135 warnings: &mut Vec<String>,
1136) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1137 let legend_scales = legend_scales_for(scales, suppressed);
1138 let n_discrete = discrete_guide_count(scales, &legend_scales);
1139 let per_guide_width = if is_horizontal {
1140 let n_continuous = continuous_guide_count(scales, &legend_scales);
1146 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
1147 ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
1148 } else {
1149 avail_width
1150 };
1151
1152 let mut plans = std::collections::HashMap::new();
1153 for aes in &legend_scales {
1154 let Some(scale) = scales.get(aes) else {
1155 continue;
1156 };
1157 if !scale.is_discrete() {
1158 continue;
1159 }
1160 let breaks = scale.breaks();
1161 let validated_guide = guide.validated(breaks.len(), warnings);
1162 if let Some(plan) = plan_legend(
1163 &breaks,
1164 &validated_guide,
1165 theme,
1166 per_guide_width,
1167 avail_height,
1168 is_horizontal,
1169 ) {
1170 plans.insert(aes.clone(), plan);
1171 }
1172 }
1173 plans
1174}
1175
1176#[allow(clippy::too_many_arguments)]
1186pub(crate) fn draw_legend_planned(
1187 scales: &ScaleSet,
1188 theme: &Theme,
1189 plot_area: &Rect,
1190 legend_avail_width: f64,
1200 backend: &mut dyn DrawBackend,
1201 guide: &GuideLegend,
1202 suppressed: &std::collections::HashSet<Aesthetic>,
1203 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1209) -> Result<(), RenderError> {
1210 if matches!(theme.legend_position, LegendPosition::None) {
1211 return Ok(());
1212 }
1213
1214 let legend_scales = legend_scales_for(scales, suppressed);
1215 if legend_scales.is_empty() {
1216 return Ok(());
1217 }
1218
1219 let is_horizontal_hint = match theme.legend_direction {
1231 Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
1232 None => matches!(
1233 theme.legend_position,
1234 LegendPosition::Top | LegendPosition::Bottom
1235 ),
1236 };
1237 let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
1238 let legend_size_hint = estimate_legend_box(scales, &legend_scales, theme, guide, legend_plans);
1242 let canvas = backend.total_area();
1243 let (legend_x, legend_y, mut is_horizontal) = legend_position(
1244 theme,
1245 plot_area,
1246 legend_extra_rows,
1247 legend_size_hint,
1248 &canvas,
1249 );
1250 if let Some(dir) = theme.legend_direction {
1251 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
1252 }
1253 let n_discrete = discrete_guide_count(scales, &legend_scales);
1254 let n_continuous = continuous_guide_count(scales, &legend_scales);
1259 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
1260 let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
1261
1262 let mut offset_y = legend_y;
1263 let mut offset_x = legend_x;
1264
1265 for aes in &legend_scales {
1266 let scale = scales.get(aes).unwrap();
1267 if !scale.is_discrete() {
1268 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
1271 let height =
1272 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
1273 if is_horizontal {
1274 offset_x += theme.legend_key_width
1275 + theme.legend_text.size * 6.0
1276 + theme.legend_spacing * 2.0;
1277 } else {
1278 offset_y += height + theme.legend_spacing * 2.0;
1279 }
1280 } else {
1281 let height = draw_discrete_legend_at(
1282 scales,
1283 aes,
1284 scale,
1285 theme,
1286 offset_x,
1287 offset_y,
1288 backend,
1289 guide,
1290 is_horizontal,
1291 )?;
1292 if is_horizontal {
1293 offset_x += theme.legend_key_width
1294 + theme.legend_text.size * 6.0
1295 + theme.legend_spacing * 2.0;
1296 } else {
1297 offset_y += height + theme.legend_spacing * 2.0;
1298 }
1299 }
1300 continue;
1301 }
1302
1303 match legend_plans.get(aes) {
1304 None => {
1305 if is_horizontal {
1306 let width = draw_discrete_legend_at(
1307 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1308 )?;
1309 offset_x += width + theme.legend_spacing * 2.0;
1310 } else {
1311 let height = draw_discrete_legend_at(
1312 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1313 )?;
1314 offset_y += height + theme.legend_spacing * 2.0;
1315 }
1316 }
1317 Some(plan) => {
1318 let height = draw_discrete_legend_grid(
1319 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1320 )?;
1321 if is_horizontal {
1322 offset_x += per_guide_width;
1326 } else {
1327 offset_y += height + theme.legend_spacing * 2.0;
1328 }
1329 }
1330 }
1331 }
1332
1333 Ok(())
1334}
1335
1336#[allow(clippy::too_many_arguments)]
1340fn draw_discrete_legend_grid(
1341 scales: &ScaleSet,
1342 aes: &Aesthetic,
1343 scale: &dyn crate::scale::Scale,
1344 theme: &Theme,
1345 legend_x: f64,
1346 legend_y: f64,
1347 backend: &mut dyn DrawBackend,
1348 guide: &GuideLegend,
1349 plan: &LegendPlan,
1350) -> Result<f64, RenderError> {
1351 let mut breaks = scale.breaks();
1352 if breaks.is_empty() {
1353 return Ok(0.0);
1354 }
1355 if guide.reverse {
1356 breaks.reverse();
1357 }
1358
1359 let diameter = size_key_diameter(aes, scales, scale, &breaks);
1361 let swatch_size = theme.legend_key_width.max(diameter);
1362 let item_height = theme.legend_key_height.max(swatch_size);
1363
1364 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1365 let legend_family = if theme.legend_title.family.is_empty() {
1366 None
1367 } else {
1368 Some(theme.legend_title.family.clone())
1369 };
1370 let title_offset = if !title.is_empty() {
1371 backend.draw_text(
1372 title,
1373 (legend_x, legend_y),
1374 &TextStyle {
1375 color: theme.legend_title.color,
1376 size: theme.legend_title.size,
1377 anchor: TextAnchor::Start,
1378 angle: 0.0,
1379 family: legend_family,
1380 face: theme.legend_title.face,
1381 bg_colour: None,
1382 bg_r: 0.0,
1383 },
1384 )?;
1385 theme.legend_title.size + 4.0
1386 } else {
1387 0.0
1388 };
1389 let items_y = legend_y + title_offset;
1390
1391 for entry in &plan.entries {
1392 let (pos, label) = &breaks[entry.break_idx];
1393 let x = legend_x + entry.x_offset;
1394 let y = items_y + entry.row as f64 * item_height;
1395 let center_x = x + swatch_size / 2.0;
1396 let center_y = y + swatch_size / 2.0;
1397
1398 if theme.legend_key.visible {
1399 if let Some(fill) = theme.legend_key.fill {
1400 backend.draw_rect(
1401 (x, y),
1402 (x + swatch_size, y + swatch_size),
1403 &RectStyle {
1404 fill: Some(fill),
1405 stroke: theme.legend_key.color,
1406 stroke_width: theme.legend_key.width,
1407 alpha: 1.0,
1408 clip: false,
1409 corner_radius: 0.0,
1410 },
1411 )?;
1412 }
1413 }
1414
1415 draw_legend_swatch(
1416 backend,
1417 scales,
1418 aes,
1419 scale,
1420 theme,
1421 label,
1422 *pos,
1423 x,
1424 y,
1425 center_x,
1426 center_y,
1427 swatch_size,
1428 )?;
1429
1430 let label_family = if theme.legend_text.family.is_empty() {
1431 None
1432 } else {
1433 Some(theme.legend_text.family.clone())
1434 };
1435 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1439 backend.draw_text_with_title(
1440 display,
1441 (x + swatch_size + theme.legend_spacing, center_y),
1442 &TextStyle {
1443 color: theme.legend_text.color,
1444 size: theme.legend_text.size,
1445 anchor: TextAnchor::Start,
1446 angle: 0.0,
1447 family: label_family,
1448 face: theme.legend_text.face,
1449 bg_colour: None,
1450 bg_r: 0.0,
1451 },
1452 entry.label_override.as_deref().map(|_| label.as_str()),
1453 )?;
1454 }
1455
1456 if let Some(ov) = &plan.overflow {
1458 let x = legend_x + ov.x_offset;
1459 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1460 let label_family = if theme.legend_text.family.is_empty() {
1461 None
1462 } else {
1463 Some(theme.legend_text.family.clone())
1464 };
1465 backend.draw_text(
1466 &ov.text,
1467 (x, y),
1468 &TextStyle {
1469 color: theme.legend_text.color,
1470 size: theme.legend_text.size,
1471 anchor: TextAnchor::Start,
1472 angle: 0.0,
1473 family: label_family,
1474 face: theme.legend_text.face,
1475 bg_colour: None,
1476 bg_r: 0.0,
1477 },
1478 )?;
1479 }
1480
1481 Ok(title_offset + plan.rows as f64 * item_height)
1482}