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 legend_scales.push(aes.clone());
51 }
52 }
53 }
54 legend_scales
55}
56
57pub(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#[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 let mut legend_scales: Vec<&Aesthetic> = Vec::new();
142 for aes in LEGEND_AESTHETICS {
143 if suppressed.contains(aes) {
145 continue;
146 }
147 if let Some(scale) = scales.get(aes) {
148 if !scale.breaks().is_empty() {
149 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 let (legend_x, legend_y, mut is_horizontal) =
170 legend_position(theme, plot_area, 0, (0.0, 0.0), plot_area);
171 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 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 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 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
255fn 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
279pub(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
290pub(crate) fn top_legend_lift(
297 theme: &Theme,
298 scales: &ScaleSet,
299 suppressed: &std::collections::HashSet<Aesthetic>,
300 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
301) -> f64 {
302 if !matches!(theme.legend_position, LegendPosition::Top) {
303 return 0.0;
304 }
305 if legend_scales_for(scales, suppressed).is_empty() {
306 return 0.0;
307 }
308 let is_horizontal = match theme.legend_direction {
309 Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
310 None => true, };
312 legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans))
313}
314
315fn estimate_legend_box(
328 scales: &ScaleSet,
329 legend_scales: &[Aesthetic],
330 theme: &Theme,
331 guide: &GuideLegend,
332 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
333) -> (f64, f64) {
334 let mut total_h = 0.0_f64;
335 let mut max_w = 0.0_f64;
336 for aes in legend_scales {
337 let Some(scale) = scales.get(aes) else {
338 continue;
339 };
340 let breaks = scale.breaks();
341 if breaks.is_empty() {
342 continue;
343 }
344 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
345 let title_offset = if !title.is_empty() {
346 theme.legend_title.size + 4.0
347 } else {
348 0.0
349 };
350 let diameter = size_key_diameter(aes, scales, scale, &breaks);
353 let key_width = theme.legend_key_width.max(diameter);
354 let key_height = theme.legend_key_height.max(key_width);
355 let single_col_width = key_width + theme.legend_spacing + theme.legend_text.size * 6.0;
356 let is_continuous_bar =
357 matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
358 let (w, h) = if is_continuous_bar {
359 (single_col_width, title_offset + key_height * 8.0)
360 } else if let Some(plan) = legend_plans.get(aes) {
361 let w = if plan.col_pitch > 0.0 {
362 plan.cols as f64 * plan.col_pitch
363 } else {
364 single_col_width
365 };
366 (w, title_offset + plan.rows as f64 * key_height)
367 } else {
368 (
369 single_col_width,
370 title_offset + breaks.len() as f64 * key_height,
371 )
372 };
373 total_h += h;
374 max_w = max_w.max(w);
375 }
376 (max_w, total_h)
377}
378
379fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
385 if size >= hi - lo {
386 lo
387 } else {
388 pos.clamp(lo, hi - size)
389 }
390}
391
392pub(crate) fn legend_position(
427 theme: &Theme,
428 plot_area: &Rect,
429 legend_extra_rows: usize,
430 legend_size_hint: (f64, f64),
431 canvas: &Rect,
432) -> (f64, f64, bool) {
433 match theme.legend_position {
434 LegendPosition::Right => (
435 plot_area.x + plot_area.width + theme.legend_margin.left,
436 plot_area.y + theme.legend_margin.top,
437 false,
438 ),
439 LegendPosition::Left => (
440 theme.legend_margin.left,
441 plot_area.y + theme.legend_margin.top,
442 false,
443 ),
444 LegendPosition::Top => (
445 plot_area.x + theme.legend_margin.left,
446 plot_area.y - legend_band_height(theme, legend_extra_rows) + theme.legend_margin.top,
447 true,
448 ),
449 LegendPosition::Bottom => (
450 plot_area.x + theme.legend_margin.left,
451 plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
452 true,
453 ),
454 LegendPosition::None => (0.0, 0.0, false),
455 LegendPosition::Inside(fx, fy) => {
456 let (legend_w, legend_h) = legend_size_hint;
457 let raw_x = plot_area.x + fx * (plot_area.width - legend_w);
458 let raw_y = plot_area.y + (1.0 - fy) * (plot_area.height - legend_h);
461 let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
462 let y = clamp_span(raw_y, legend_h, canvas.y, canvas.y + canvas.height);
463 (x, y, false)
464 }
465 }
466}
467
468pub(crate) fn size_key_diameter(
478 aes: &Aesthetic,
479 scales: &ScaleSet,
480 scale: &dyn crate::scale::Scale,
481 breaks: &[(f64, String)],
482) -> f64 {
483 if *aes != Aesthetic::Size {
484 return 0.0;
485 }
486 let max_r = breaks
487 .iter()
488 .map(|(pos, label)| {
489 scale
490 .map_fraction_to_size(*pos)
491 .or_else(|| scales.map_size(&Value::Str(label.clone())))
492 .unwrap_or(3.0)
493 })
494 .fold(0.0_f64, f64::max);
495 max_r * 2.0
496}
497
498#[allow(clippy::too_many_arguments)]
500fn draw_discrete_legend_at(
510 scales: &ScaleSet,
511 group: &[(Aesthetic, &dyn crate::scale::Scale)],
512 theme: &Theme,
513 legend_x: f64,
514 legend_y: f64,
515 backend: &mut dyn DrawBackend,
516 guide: &GuideLegend,
517 is_horizontal: bool,
518) -> Result<f64, RenderError> {
519 let (_, scale) = &group[0];
520 let mut breaks = scale.breaks();
521 if breaks.is_empty() {
522 return Ok(0.0);
523 }
524
525 if guide.reverse {
527 breaks.reverse();
528 }
529
530 let diameter = group
538 .iter()
539 .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
540 .fold(0.0_f64, f64::max);
541 let swatch_size = theme.legend_key_width.max(diameter);
542 let item_height = theme.legend_key_height.max(swatch_size);
543
544 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
546 let legend_family = if theme.legend_title.family.is_empty() {
547 None
548 } else {
549 Some(theme.legend_title.family.clone())
550 };
551 let title_offset = if !title.is_empty() {
552 backend.draw_text(
553 title,
554 (legend_x, legend_y),
555 &TextStyle {
556 color: theme.legend_title.color,
557 size: theme.legend_title.size,
558 anchor: TextAnchor::Start,
559 angle: 0.0,
560 family: legend_family,
561 face: theme.legend_title.face,
562 bg_colour: None,
563 bg_r: 0.0,
564 },
565 )?;
566 theme.legend_title.size + 4.0
567 } else {
568 0.0
569 };
570
571 let items_y = legend_y + title_offset;
572
573 if theme.legend_background.visible {
575 let total_height = breaks.len() as f64 * item_height;
576 let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
577 if let Some(fill) = theme.legend_background.fill {
578 backend.draw_rect(
579 (legend_x - 2.0, items_y - 2.0),
580 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
581 &RectStyle {
582 fill: Some(fill),
583 stroke: theme.legend_background.color,
584 stroke_width: theme.legend_background.width,
585 alpha: 1.0,
586 clip: false,
587 corner_radius: 0.0,
588 },
589 )?;
590 }
591 }
592
593 let mut cur_x = legend_x;
595 for (i, (pos, label)) in breaks.iter().enumerate() {
596 let x = if is_horizontal { cur_x } else { legend_x };
597 let y = if is_horizontal {
598 items_y
599 } else {
600 items_y + i as f64 * item_height
601 };
602 let center_x = x + swatch_size / 2.0;
603 let center_y = y + swatch_size / 2.0;
604
605 if theme.legend_key.visible {
607 if let Some(fill) = theme.legend_key.fill {
608 backend.draw_rect(
609 (x, y),
610 (x + swatch_size, y + swatch_size),
611 &RectStyle {
612 fill: Some(fill),
613 stroke: theme.legend_key.color,
614 stroke_width: theme.legend_key.width,
615 alpha: 1.0,
616 clip: false,
617 corner_radius: 0.0,
618 },
619 )?;
620 }
621 }
622
623 for (member_aes, member_scale) in group {
627 draw_legend_swatch(
628 backend,
629 scales,
630 member_aes,
631 *member_scale,
632 theme,
633 label,
634 *pos,
635 x,
636 y,
637 center_x,
638 center_y,
639 swatch_size,
640 )?;
641 }
642
643 let label_family = if theme.legend_text.family.is_empty() {
645 None
646 } else {
647 Some(theme.legend_text.family.clone())
648 };
649 backend.draw_text(
650 label,
651 (x + swatch_size + theme.legend_spacing, center_y),
652 &TextStyle {
653 color: theme.legend_text.color,
654 size: theme.legend_text.size,
655 anchor: TextAnchor::Start,
656 angle: 0.0,
657 family: label_family,
658 face: theme.legend_text.face,
659 bg_colour: None,
660 bg_r: 0.0,
661 },
662 )?;
663 if is_horizontal {
664 cur_x = x
666 + swatch_size
667 + theme.legend_spacing
668 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
669 + theme.legend_spacing * 3.0;
670 }
671 }
672
673 if is_horizontal {
674 Ok(cur_x - legend_x) } else {
676 Ok(title_offset + breaks.len() as f64 * item_height)
677 }
678}
679
680fn continuous_bar_dims(theme: &Theme, is_horizontal: bool) -> (f64, f64) {
689 if is_horizontal {
690 (theme.legend_key_height * 8.0, theme.legend_key_width)
691 } else {
692 (theme.legend_key_width, theme.legend_key_height * 8.0)
693 }
694}
695
696pub(crate) fn continuous_horizontal_bar_height(theme: &Theme, has_title: bool) -> f64 {
703 let title_offset = if has_title {
704 theme.legend_title.size + 4.0
705 } else {
706 0.0
707 };
708 let (_, bar_thickness) = continuous_bar_dims(theme, true);
709 let tick_len = 3.0;
710 let label_line = theme.legend_text.size + 4.0;
711 title_offset + bar_thickness + tick_len + theme.legend_spacing + label_line
712}
713
714fn draw_continuous_legend_at(
721 scale: &dyn crate::scale::Scale,
722 theme: &Theme,
723 legend_x: f64,
724 legend_y: f64,
725 backend: &mut dyn DrawBackend,
726 guide: &GuideLegend,
727 is_horizontal: bool,
728) -> Result<f64, RenderError> {
729 let breaks = scale.breaks();
730 if breaks.is_empty() {
731 return Ok(0.0);
732 }
733
734 let (bar_width, bar_height) = continuous_bar_dims(theme, is_horizontal);
735
736 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
738 let cont_family = if theme.legend_title.family.is_empty() {
739 None
740 } else {
741 Some(theme.legend_title.family.clone())
742 };
743 let title_offset = if !title.is_empty() {
744 backend.draw_text(
745 title,
746 (legend_x, legend_y),
747 &TextStyle {
748 color: theme.legend_title.color,
749 size: theme.legend_title.size,
750 anchor: TextAnchor::Start,
751 angle: 0.0,
752 family: cont_family,
753 face: theme.legend_title.face,
754 bg_colour: None,
755 bg_r: 0.0,
756 },
757 )?;
758 theme.legend_title.size + 4.0
759 } else {
760 0.0
761 };
762
763 let bar_top = legend_y + title_offset;
764
765 if theme.legend_background.visible {
767 let total_width = if is_horizontal {
768 bar_width
769 } else {
770 bar_width + theme.legend_spacing + theme.legend_text.size * 6.0
771 };
772 let total_height = if is_horizontal {
773 bar_height + theme.legend_spacing + theme.legend_text.size * 2.0
774 } else {
775 bar_height
776 };
777 if let Some(fill) = theme.legend_background.fill {
778 backend.draw_rect(
779 (legend_x - 2.0, bar_top - 2.0),
780 (legend_x + total_width + 2.0, bar_top + total_height + 2.0),
781 &RectStyle {
782 fill: Some(fill),
783 stroke: theme.legend_background.color,
784 stroke_width: theme.legend_background.width,
785 alpha: 1.0,
786 clip: false,
787 corner_radius: 0.0,
788 },
789 )?;
790 }
791 }
792
793 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
798 let n_slices = 50;
799 if is_horizontal {
800 let slice_width = bar_width / n_slices as f64;
801 for i in 0..n_slices {
802 let t = i as f64 / n_slices as f64;
803 let data_val = data_min + t * (data_max - data_min);
804 let color = scale
805 .map_to_color(&Value::Float(data_val))
806 .unwrap_or((127, 127, 127));
807 let sx = legend_x + i as f64 * slice_width;
808 backend.draw_rect(
809 (sx, bar_top),
810 (sx + slice_width + 0.5, bar_top + bar_height),
811 &RectStyle {
812 fill: Some(color),
813 stroke: None,
814 stroke_width: 0.0,
815 alpha: 1.0,
816 clip: false,
817 corner_radius: 0.0,
818 },
819 )?;
820 }
821 } else {
822 let slice_height = bar_height / n_slices as f64;
823 for i in 0..n_slices {
824 let t = 1.0 - i as f64 / n_slices as f64;
825 let data_val = data_min + t * (data_max - data_min);
826 let color = scale
827 .map_to_color(&Value::Float(data_val))
828 .unwrap_or((127, 127, 127));
829 let sy = bar_top + i as f64 * slice_height;
830 backend.draw_rect(
831 (legend_x, sy),
832 (legend_x + bar_width, sy + slice_height + 0.5),
833 &RectStyle {
834 fill: Some(color),
835 stroke: None,
836 stroke_width: 0.0,
837 alpha: 1.0,
838 clip: false,
839 corner_radius: 0.0,
840 },
841 )?;
842 }
843 }
844
845 let border = theme.legend_bar_border_line();
848 let border_style = LineStyle {
849 color: border.color,
850 width: border.width,
851 alpha: 1.0,
852 linetype: border.linetype,
853 ..Default::default()
854 };
855 if border.visible {
856 backend.draw_line(
857 &[
858 (legend_x, bar_top),
859 (legend_x + bar_width, bar_top),
860 (legend_x + bar_width, bar_top + bar_height),
861 (legend_x, bar_top + bar_height),
862 (legend_x, bar_top),
863 ],
864 &border_style,
865 )?;
866 }
867
868 let tick_len = 3.0;
872 let tick_family = if theme.legend_text.family.is_empty() {
873 None
874 } else {
875 Some(theme.legend_text.family.clone())
876 };
877 for (pos, label) in &breaks {
878 if is_horizontal {
879 let tick_x = legend_x + bar_width * pos;
880 backend.draw_line(
881 &[
882 (tick_x, bar_top + bar_height),
883 (tick_x, bar_top + bar_height + tick_len),
884 ],
885 &border_style,
886 )?;
887 backend.draw_text(
888 label,
889 (
890 tick_x,
891 bar_top + bar_height + tick_len + theme.legend_text.size,
892 ),
893 &TextStyle {
894 color: theme.legend_text.color,
895 size: theme.legend_text.size,
896 anchor: TextAnchor::Middle,
897 angle: 0.0,
898 family: tick_family.clone(),
899 face: theme.legend_text.face,
900 bg_colour: None,
901 bg_r: 0.0,
902 },
903 )?;
904 } else {
905 let tick_y = bar_top + bar_height * (1.0 - pos);
906 backend.draw_line(
907 &[
908 (legend_x + bar_width, tick_y),
909 (legend_x + bar_width + tick_len, tick_y),
910 ],
911 &border_style,
912 )?;
913 backend.draw_text(
914 label,
915 (
916 legend_x + bar_width + tick_len + theme.legend_spacing,
917 tick_y,
918 ),
919 &TextStyle {
920 color: theme.legend_text.color,
921 size: theme.legend_text.size,
922 anchor: TextAnchor::Start,
923 angle: 0.0,
924 family: tick_family.clone(),
925 face: theme.legend_text.face,
926 bg_colour: None,
927 bg_r: 0.0,
928 },
929 )?;
930 }
931 }
932
933 if is_horizontal {
934 Ok(continuous_horizontal_bar_height(theme, !title.is_empty()))
935 } else {
936 Ok(title_offset + bar_height)
937 }
938}
939
940#[allow(clippy::too_many_arguments)]
962fn draw_legend_swatch(
963 backend: &mut dyn DrawBackend,
964 scales: &ScaleSet,
965 aes: &Aesthetic,
966 scale: &dyn crate::scale::Scale,
967 theme: &Theme,
968 label: &str,
969 pos: f64,
970 x: f64,
971 y: f64,
972 center_x: f64,
973 center_y: f64,
974 swatch_size: f64,
975) -> Result<(), RenderError> {
976 let value = Value::Str(label.to_string());
977 match aes {
978 Aesthetic::Color | Aesthetic::Fill => {
979 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
980 let style = RectStyle {
981 fill: Some(color),
982 stroke: None,
983 stroke_width: 0.0,
984 alpha: 1.0,
985 clip: false,
986 corner_radius: 0.0,
987 };
988 if scale.is_na_break(label) {
991 if let Some(p) = scale.na_fill_pattern() {
992 return backend.draw_rect_pattern(
993 (x, y),
994 (x + swatch_size, y + swatch_size),
995 &p,
996 &style,
997 );
998 }
999 }
1000 backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
1001 }
1002 Aesthetic::Shape => {
1003 let shape = scales
1004 .map_shape(&value)
1005 .unwrap_or(crate::render::backend::PointShape::Circle);
1006 backend.draw_shape(
1007 (center_x, center_y),
1008 swatch_size / 3.0,
1009 &PointStyle {
1010 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1011 alpha: 1.0,
1012 filled: true,
1013 shape,
1014 },
1015 )?;
1016 }
1017 Aesthetic::Linetype => {
1018 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
1019 backend.draw_line(
1020 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1021 &LineStyle {
1022 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1023 width: 1.5,
1024 alpha: 1.0,
1025 linetype: lt,
1026 ..Default::default()
1027 },
1028 )?;
1029 }
1030 Aesthetic::Size => {
1031 let size = scale
1032 .map_fraction_to_size(pos)
1033 .or_else(|| scales.map_size(&value))
1034 .unwrap_or(3.0);
1035 backend.draw_shape(
1036 (center_x, center_y),
1037 size.min(swatch_size / 2.0),
1038 &PointStyle {
1039 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1040 alpha: 1.0,
1041 filled: true,
1042 shape: crate::render::backend::PointShape::Circle,
1043 },
1044 )?;
1045 }
1046 Aesthetic::Alpha => {
1047 let alpha = scale
1048 .map_fraction_to_alpha(pos)
1049 .or_else(|| scales.map_alpha(&value))
1050 .unwrap_or(1.0);
1051 backend.draw_rect(
1052 (x, y),
1053 (x + swatch_size, y + swatch_size),
1054 &RectStyle {
1055 fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
1056 stroke: None,
1057 stroke_width: 0.0,
1058 alpha,
1059 clip: false,
1060 corner_radius: 0.0,
1061 },
1062 )?;
1063 }
1064 Aesthetic::Linewidth => {
1065 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
1066 backend.draw_line(
1067 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1068 &LineStyle {
1069 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1070 width: width.min(swatch_size),
1071 alpha: 1.0,
1072 linetype: Linetype::Solid,
1073 ..Default::default()
1074 },
1075 )?;
1076 }
1077 _ => {}
1078 }
1079 Ok(())
1080}
1081
1082#[derive(Clone, Debug)]
1088pub(crate) struct LegendEntry {
1089 pub break_idx: usize,
1090 pub row: usize,
1091 pub x_offset: f64,
1092 pub label_override: Option<String>,
1099}
1100
1101#[derive(Clone, Debug)]
1105pub(crate) struct LegendOverflow {
1106 pub dropped: usize,
1107 pub row: usize,
1108 pub x_offset: f64,
1109 pub text: String,
1110}
1111
1112#[derive(Clone, Debug)]
1117pub(crate) struct LegendPlan {
1118 pub rows: usize,
1119 pub entries: Vec<LegendEntry>,
1120 pub overflow: Option<LegendOverflow>,
1121 pub cols: usize,
1126 pub col_pitch: f64,
1129}
1130
1131fn legend_pitch(label: &str, theme: &Theme) -> f64 {
1136 let swatch = theme.legend_key_width;
1137 let spacing = theme.legend_spacing;
1138 let text_size = theme.legend_text.size;
1139 let legacy =
1140 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
1141 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
1142 legacy.max(measured)
1143}
1144
1145pub(crate) fn plan_legend(
1153 breaks: &[(f64, String)],
1154 guide: &GuideLegend,
1155 theme: &Theme,
1156 avail_width: f64,
1157 avail_height: f64,
1158 is_horizontal: bool,
1159) -> Option<LegendPlan> {
1160 let n = breaks.len();
1161 if n == 0 {
1162 return None;
1163 }
1164
1165 if guide.nrow.is_some() || guide.ncol.is_some() {
1167 let (rows, cols) = match (guide.nrow, guide.ncol) {
1168 (Some(nrow), Some(ncol)) => (nrow, ncol),
1169 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
1170 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
1171 (None, None) => unreachable!("checked above"),
1172 };
1173 let rows = rows.max(1);
1174 let cols = cols.max(1);
1175 let col_pitch = breaks
1176 .iter()
1177 .map(|(_, l)| legend_pitch(l, theme))
1178 .fold(0.0_f64, f64::max);
1179 let entries = (0..n)
1180 .map(|i| {
1181 let (row, col) = if guide.byrow {
1182 (i / cols, i % cols)
1183 } else {
1184 (i % rows, i / rows)
1185 };
1186 LegendEntry {
1187 break_idx: i,
1188 row,
1189 x_offset: col as f64 * col_pitch,
1190 label_override: None,
1191 }
1192 })
1193 .collect();
1194 return Some(LegendPlan {
1195 rows,
1196 entries,
1197 overflow: None,
1198 cols,
1199 col_pitch,
1200 });
1201 }
1202
1203 if !is_horizontal {
1205 return None;
1206 }
1207
1208 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1209 let total_width: f64 = pitches.iter().sum();
1210 if total_width <= avail_width.max(0.0) {
1211 return None; }
1213
1214 let item_height = theme.legend_key_height.max(1.0);
1215 let row_limit = guide
1216 .max_rows
1217 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1218 .max(1);
1219
1220 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1221 let mut row = 0usize;
1222 let mut cur_x = 0.0_f64;
1223 for (i, &p) in pitches.iter().enumerate() {
1224 if cur_x > 0.0 && cur_x + p > avail_width {
1225 if row + 1 >= row_limit {
1226 break; }
1228 row += 1;
1229 cur_x = 0.0;
1230 }
1231 entries.push(LegendEntry {
1232 break_idx: i,
1233 row,
1234 x_offset: cur_x,
1235 label_override: None,
1236 });
1237 cur_x += p;
1238 }
1239
1240 if entries.len() >= n {
1241 return Some(LegendPlan {
1242 rows: row + 1,
1243 entries,
1244 overflow: None,
1245 cols: 1,
1246 col_pitch: 0.0,
1247 });
1248 }
1249
1250 let mut dropped = n - entries.len();
1253 loop {
1254 let text = format!("+{dropped} more");
1255 let more_pitch = legend_pitch(&text, theme);
1256 let row_count = entries.iter().filter(|e| e.row == row).count();
1257 let last_x = entries
1258 .iter()
1259 .filter(|e| e.row == row)
1260 .map(|e| e.x_offset + pitches[e.break_idx])
1261 .fold(0.0_f64, f64::max);
1262 if last_x + more_pitch <= avail_width || row_count == 0 {
1263 return Some(LegendPlan {
1264 rows: row + 1,
1265 entries,
1266 overflow: Some(LegendOverflow {
1267 dropped,
1268 row,
1269 x_offset: last_x,
1270 text,
1271 }),
1272 cols: 1,
1273 col_pitch: 0.0,
1274 });
1275 }
1276 entries.pop();
1277 dropped += 1;
1278 }
1279}
1280
1281fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1285 legend_scales
1286 .iter()
1287 .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
1288 .count()
1289 .max(1)
1290}
1291
1292fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1299 legend_scales
1300 .iter()
1301 .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
1302 .count()
1303}
1304
1305fn continuous_guide_width(theme: &Theme) -> f64 {
1310 let (bar_len, _) = continuous_bar_dims(theme, true);
1315 bar_len + theme.legend_spacing * 2.0
1316}
1317
1318#[allow(clippy::too_many_arguments)]
1332pub(crate) fn plan_all_legends(
1333 scales: &ScaleSet,
1334 guide: &GuideLegend,
1335 theme: &Theme,
1336 suppressed: &std::collections::HashSet<Aesthetic>,
1337 avail_width: f64,
1338 avail_height: f64,
1339 is_horizontal: bool,
1340 warnings: &mut Vec<String>,
1341) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1342 let legend_scales = legend_scales_for(scales, suppressed);
1343 let n_discrete = discrete_guide_count(scales, &legend_scales);
1344 let per_guide_width = if is_horizontal {
1345 let n_continuous = continuous_guide_count(scales, &legend_scales);
1351 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
1352 ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
1353 } else {
1354 avail_width
1355 };
1356
1357 let mut plans = std::collections::HashMap::new();
1358 for aes in &legend_scales {
1359 let Some(scale) = scales.get(aes) else {
1360 continue;
1361 };
1362 if !scale.is_discrete() {
1363 continue;
1364 }
1365 let breaks = scale.breaks();
1366 let validated_guide = guide.validated(breaks.len(), warnings);
1367 let layout_breaks = scale.breaks_for_layout();
1373 if let Some(plan) = plan_legend(
1374 &layout_breaks,
1375 &validated_guide,
1376 theme,
1377 per_guide_width,
1378 avail_height,
1379 is_horizontal,
1380 ) {
1381 plans.insert(aes.clone(), plan);
1382 }
1383 }
1384 plans
1385}
1386
1387pub(crate) fn inside_legend_box(
1408 scales: &ScaleSet,
1409 theme: &Theme,
1410 plot_area: &Rect,
1411 canvas: &Rect,
1412 guide: &GuideLegend,
1413 suppressed: &std::collections::HashSet<Aesthetic>,
1414 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1415) -> Option<Rect> {
1416 if !matches!(theme.legend_position, LegendPosition::Inside(..)) {
1417 return None;
1418 }
1419 let legend_scales = legend_scales_for(scales, suppressed);
1420 if legend_scales.is_empty() {
1421 return None;
1422 }
1423 let groups = group_mergeable_legends(scales, &legend_scales, guide);
1424 let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
1425 let legend_size_hint =
1426 estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
1427 let (legend_x, legend_y, _) = legend_position(theme, plot_area, 0, legend_size_hint, canvas);
1428 Some(Rect {
1429 x: legend_x,
1430 y: legend_y,
1431 width: legend_size_hint.0,
1432 height: legend_size_hint.1,
1433 })
1434}
1435
1436#[allow(clippy::too_many_arguments)]
1437pub(crate) fn draw_legend_planned(
1438 scales: &ScaleSet,
1439 theme: &Theme,
1440 plot_area: &Rect,
1441 legend_avail_width: f64,
1451 backend: &mut dyn DrawBackend,
1452 guide: &GuideLegend,
1453 suppressed: &std::collections::HashSet<Aesthetic>,
1454 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
1460) -> Result<(), RenderError> {
1461 if matches!(theme.legend_position, LegendPosition::None) {
1462 return Ok(());
1463 }
1464
1465 let legend_scales = legend_scales_for(scales, suppressed);
1466 if legend_scales.is_empty() {
1467 return Ok(());
1468 }
1469
1470 let groups = group_mergeable_legends(scales, &legend_scales, guide);
1480 let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
1481
1482 let is_horizontal_hint = match theme.legend_direction {
1494 Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
1495 None => matches!(
1496 theme.legend_position,
1497 LegendPosition::Top | LegendPosition::Bottom
1498 ),
1499 };
1500 let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
1501 let legend_size_hint =
1507 estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
1508 let canvas = backend.total_area();
1509 let (legend_x, legend_y, mut is_horizontal) = legend_position(
1510 theme,
1511 plot_area,
1512 legend_extra_rows,
1513 legend_size_hint,
1514 &canvas,
1515 );
1516 if let Some(dir) = theme.legend_direction {
1517 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
1518 }
1519 let n_discrete = discrete_guide_count(scales, &representatives);
1520 let n_continuous = continuous_guide_count(scales, &representatives);
1525 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
1526 let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
1527
1528 let mut offset_y = legend_y;
1529 let mut offset_x = legend_x;
1530
1531 for group in &groups {
1532 let representative = &group[0];
1533 let rep_scale = scales.get(representative).unwrap();
1534 let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
1535 .iter()
1536 .map(|a| (a.clone(), scales.get(a).unwrap()))
1537 .collect();
1538
1539 if !rep_scale.is_discrete() {
1540 if matches!(representative, Aesthetic::Color | Aesthetic::Fill) {
1543 let height = draw_continuous_legend_at(
1549 rep_scale,
1550 theme,
1551 offset_x,
1552 offset_y,
1553 backend,
1554 guide,
1555 is_horizontal,
1556 )?;
1557 if is_horizontal {
1558 let (bar_len, _) = continuous_bar_dims(theme, true);
1562 offset_x += bar_len + theme.legend_spacing * 2.0;
1563 } else {
1564 offset_y += height + theme.legend_spacing * 2.0;
1565 }
1566 } else {
1567 let height = draw_discrete_legend_at(
1568 scales,
1569 &scale_group,
1570 theme,
1571 offset_x,
1572 offset_y,
1573 backend,
1574 guide,
1575 is_horizontal,
1576 )?;
1577 if is_horizontal {
1578 offset_x += theme.legend_key_width
1579 + theme.legend_text.size * 6.0
1580 + theme.legend_spacing * 2.0;
1581 } else {
1582 offset_y += height + theme.legend_spacing * 2.0;
1583 }
1584 }
1585 continue;
1586 }
1587
1588 match legend_plans.get(representative) {
1589 None => {
1590 if is_horizontal {
1591 let width = draw_discrete_legend_at(
1592 scales,
1593 &scale_group,
1594 theme,
1595 offset_x,
1596 offset_y,
1597 backend,
1598 guide,
1599 true,
1600 )?;
1601 offset_x += width + theme.legend_spacing * 2.0;
1602 } else {
1603 let height = draw_discrete_legend_at(
1604 scales,
1605 &scale_group,
1606 theme,
1607 offset_x,
1608 offset_y,
1609 backend,
1610 guide,
1611 false,
1612 )?;
1613 offset_y += height + theme.legend_spacing * 2.0;
1614 }
1615 }
1616 Some(plan) => {
1617 let height = draw_discrete_legend_grid(
1618 scales,
1619 &scale_group,
1620 theme,
1621 offset_x,
1622 offset_y,
1623 backend,
1624 guide,
1625 plan,
1626 )?;
1627 if is_horizontal {
1628 offset_x += per_guide_width;
1632 } else {
1633 offset_y += height + theme.legend_spacing * 2.0;
1634 }
1635 }
1636 }
1637 }
1638
1639 Ok(())
1640}
1641
1642#[allow(clippy::too_many_arguments)]
1646fn draw_discrete_legend_grid(
1649 scales: &ScaleSet,
1650 group: &[(Aesthetic, &dyn crate::scale::Scale)],
1651 theme: &Theme,
1652 legend_x: f64,
1653 legend_y: f64,
1654 backend: &mut dyn DrawBackend,
1655 guide: &GuideLegend,
1656 plan: &LegendPlan,
1657) -> Result<f64, RenderError> {
1658 let (_, scale) = &group[0];
1659 let mut breaks = scale.breaks();
1660 if breaks.is_empty() {
1661 return Ok(0.0);
1662 }
1663 if guide.reverse {
1664 breaks.reverse();
1665 }
1666
1667 let diameter = group
1669 .iter()
1670 .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
1671 .fold(0.0_f64, f64::max);
1672 let swatch_size = theme.legend_key_width.max(diameter);
1673 let item_height = theme.legend_key_height.max(swatch_size);
1674
1675 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1676 let legend_family = if theme.legend_title.family.is_empty() {
1677 None
1678 } else {
1679 Some(theme.legend_title.family.clone())
1680 };
1681 let title_offset = if !title.is_empty() {
1682 backend.draw_text(
1683 title,
1684 (legend_x, legend_y),
1685 &TextStyle {
1686 color: theme.legend_title.color,
1687 size: theme.legend_title.size,
1688 anchor: TextAnchor::Start,
1689 angle: 0.0,
1690 family: legend_family,
1691 face: theme.legend_title.face,
1692 bg_colour: None,
1693 bg_r: 0.0,
1694 },
1695 )?;
1696 theme.legend_title.size + 4.0
1697 } else {
1698 0.0
1699 };
1700 let items_y = legend_y + title_offset;
1701
1702 for entry in &plan.entries {
1703 let (pos, label) = &breaks[entry.break_idx];
1704 let x = legend_x + entry.x_offset;
1705 let y = items_y + entry.row as f64 * item_height;
1706 let center_x = x + swatch_size / 2.0;
1707 let center_y = y + swatch_size / 2.0;
1708
1709 if theme.legend_key.visible {
1710 if let Some(fill) = theme.legend_key.fill {
1711 backend.draw_rect(
1712 (x, y),
1713 (x + swatch_size, y + swatch_size),
1714 &RectStyle {
1715 fill: Some(fill),
1716 stroke: theme.legend_key.color,
1717 stroke_width: theme.legend_key.width,
1718 alpha: 1.0,
1719 clip: false,
1720 corner_radius: 0.0,
1721 },
1722 )?;
1723 }
1724 }
1725
1726 for (member_aes, member_scale) in group {
1727 draw_legend_swatch(
1728 backend,
1729 scales,
1730 member_aes,
1731 *member_scale,
1732 theme,
1733 label,
1734 *pos,
1735 x,
1736 y,
1737 center_x,
1738 center_y,
1739 swatch_size,
1740 )?;
1741 }
1742
1743 let label_family = if theme.legend_text.family.is_empty() {
1744 None
1745 } else {
1746 Some(theme.legend_text.family.clone())
1747 };
1748 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1752 backend.draw_text_with_title(
1753 display,
1754 (x + swatch_size + theme.legend_spacing, center_y),
1755 &TextStyle {
1756 color: theme.legend_text.color,
1757 size: theme.legend_text.size,
1758 anchor: TextAnchor::Start,
1759 angle: 0.0,
1760 family: label_family,
1761 face: theme.legend_text.face,
1762 bg_colour: None,
1763 bg_r: 0.0,
1764 },
1765 entry.label_override.as_deref().map(|_| label.as_str()),
1766 )?;
1767 }
1768
1769 if let Some(ov) = &plan.overflow {
1771 let x = legend_x + ov.x_offset;
1772 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1773 let label_family = if theme.legend_text.family.is_empty() {
1774 None
1775 } else {
1776 Some(theme.legend_text.family.clone())
1777 };
1778 backend.draw_text(
1779 &ov.text,
1780 (x, y),
1781 &TextStyle {
1782 color: theme.legend_text.color,
1783 size: theme.legend_text.size,
1784 anchor: TextAnchor::Start,
1785 angle: 0.0,
1786 family: label_family,
1787 face: theme.legend_text.face,
1788 bg_colour: None,
1789 bg_r: 0.0,
1790 },
1791 )?;
1792 }
1793
1794 Ok(title_offset + plan.rows as f64 * item_height)
1795}
1796
1797#[cfg(test)]
1798mod group_mergeable_legends_tests {
1799 use super::*;
1800 use crate::scale::color::RGBAColor;
1801 use crate::scale::manual::ScaleManual;
1802
1803 #[test]
1804 fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
1805 let mut scales = ScaleSet::new();
1812 scales.add(Box::new(ScaleManual::new(
1813 Aesthetic::Color,
1814 vec![("a", RGBAColor::new(200, 0, 0))],
1815 )));
1816 let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
1817 let guide = GuideLegend::default();
1818 let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
1819 assert_eq!(groups, vec![vec![Aesthetic::Color]]);
1820 }
1821}
1822
1823#[cfg(test)]
1829mod continuous_bar_dims_tests {
1830 use super::*;
1831
1832 #[test]
1833 fn dims_swap_between_vertical_and_horizontal() {
1834 let theme = Theme::default();
1835 let (w, h) = continuous_bar_dims(&theme, false);
1836 assert_eq!(w, theme.legend_key_width);
1837 assert_eq!(h, theme.legend_key_height * 8.0);
1838
1839 let (w, h) = continuous_bar_dims(&theme, true);
1840 assert_eq!(w, theme.legend_key_height * 8.0);
1841 assert_eq!(h, theme.legend_key_width);
1842 }
1843
1844 #[test]
1845 fn horizontal_bar_height_adds_a_title_line_only_when_there_is_a_title() {
1846 let theme = Theme::default();
1847 let without_title = continuous_horizontal_bar_height(&theme, false);
1848 let with_title = continuous_horizontal_bar_height(&theme, true);
1849 assert!(with_title > without_title);
1850 assert!(((with_title - without_title) - (theme.legend_title.size + 4.0)).abs() < 1e-9);
1851
1852 let (_, thickness) = continuous_bar_dims(&theme, true);
1857 let tick_len = 3.0;
1858 let label_line = theme.legend_text.size + 4.0;
1859 assert!(
1860 (without_title - (thickness + tick_len + theme.legend_spacing + label_line)).abs()
1861 < 1e-9
1862 );
1863 }
1864}
1865
1866#[cfg(test)]
1869mod inside_legend_box_tests {
1870 use super::*;
1871 use crate::scale::color::RGBAColor;
1872 use crate::scale::manual::ScaleManual;
1873
1874 fn scales_with_a_legend() -> ScaleSet {
1875 let mut scales = ScaleSet::new();
1876 scales.add(Box::new(ScaleManual::new(
1877 Aesthetic::Color,
1878 vec![
1879 ("a", RGBAColor::new(200, 0, 0)),
1880 ("b", RGBAColor::new(0, 0, 200)),
1881 ],
1882 )));
1883 scales
1884 }
1885
1886 fn areas() -> (Rect, Rect) {
1887 let plot_area = Rect {
1888 x: 10.0,
1889 y: 10.0,
1890 width: 200.0,
1891 height: 150.0,
1892 };
1893 let canvas = Rect {
1894 x: 0.0,
1895 y: 0.0,
1896 width: 220.0,
1897 height: 170.0,
1898 };
1899 (plot_area, canvas)
1900 }
1901
1902 #[test]
1903 fn non_inside_positions_claim_nothing() {
1904 let scales = scales_with_a_legend();
1905 let (plot_area, canvas) = areas();
1906 let guide = GuideLegend::default();
1907 let suppressed = std::collections::HashSet::new();
1908 let legend_plans = std::collections::HashMap::new();
1909 for pos in [
1910 LegendPosition::Right,
1911 LegendPosition::Left,
1912 LegendPosition::Top,
1913 LegendPosition::Bottom,
1914 LegendPosition::None,
1915 ] {
1916 let theme = Theme {
1917 legend_position: pos,
1918 ..Theme::default()
1919 };
1920 assert!(inside_legend_box(
1921 &scales,
1922 &theme,
1923 &plot_area,
1924 &canvas,
1925 &guide,
1926 &suppressed,
1927 &legend_plans
1928 )
1929 .is_none());
1930 }
1931 }
1932
1933 #[test]
1934 fn no_legend_scales_claims_nothing_even_when_inside() {
1935 let scales = ScaleSet::new(); let (plot_area, canvas) = areas();
1937 let theme = Theme {
1938 legend_position: LegendPosition::Inside(0.85, 0.85),
1939 ..Theme::default()
1940 };
1941 let guide = GuideLegend::default();
1942 let suppressed = std::collections::HashSet::new();
1943 let legend_plans = std::collections::HashMap::new();
1944 assert!(inside_legend_box(
1945 &scales,
1946 &theme,
1947 &plot_area,
1948 &canvas,
1949 &guide,
1950 &suppressed,
1951 &legend_plans
1952 )
1953 .is_none());
1954 }
1955
1956 #[test]
1957 fn inside_position_with_a_legend_claims_a_box_within_the_panel() {
1958 let scales = scales_with_a_legend();
1959 let (plot_area, canvas) = areas();
1960 let theme = Theme {
1961 legend_position: LegendPosition::Inside(0.85, 0.85),
1962 ..Theme::default()
1963 };
1964 let guide = GuideLegend::default();
1965 let suppressed = std::collections::HashSet::new();
1966 let legend_plans = std::collections::HashMap::new();
1967 let rect = inside_legend_box(
1968 &scales,
1969 &theme,
1970 &plot_area,
1971 &canvas,
1972 &guide,
1973 &suppressed,
1974 &legend_plans,
1975 )
1976 .expect("an Inside legend with a mapped scale claims a box");
1977 assert!(rect.width > 0.0 && rect.height > 0.0);
1978 assert!(rect.x >= canvas.x - 1e-6 && rect.x + rect.width <= canvas.x + canvas.width + 1e-6);
1979 assert!(
1980 rect.y >= canvas.y - 1e-6 && rect.y + rect.height <= canvas.y + canvas.height + 1e-6
1981 );
1982 }
1983
1984 #[test]
1985 fn matches_the_position_draw_legend_planned_itself_uses() {
1986 let scales = scales_with_a_legend();
1991 let (plot_area, canvas) = areas();
1992 let theme = Theme {
1993 legend_position: LegendPosition::Inside(0.85, 0.85),
1994 ..Theme::default()
1995 };
1996 let guide = GuideLegend::default();
1997 let suppressed = std::collections::HashSet::new();
1998 let legend_plans = std::collections::HashMap::new();
1999
2000 let rect = inside_legend_box(
2001 &scales,
2002 &theme,
2003 &plot_area,
2004 &canvas,
2005 &guide,
2006 &suppressed,
2007 &legend_plans,
2008 )
2009 .unwrap();
2010
2011 let legend_scales = legend_scales_for(&scales, &suppressed);
2012 let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2013 let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2014 let size_hint =
2015 estimate_legend_box(&scales, &representatives, &theme, &guide, &legend_plans);
2016 let (x, y, _) = legend_position(&theme, &plot_area, 0, size_hint, &canvas);
2017
2018 assert_eq!(
2019 (rect.x, rect.y, rect.width, rect.height),
2020 (x, y, size_hint.0, size_hint.1)
2021 );
2022 }
2023}