1use crate::aes::Aesthetic;
2use crate::data::Value;
3use crate::guide::config::{ColourbarDisplay, GuideColourbar, 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
11pub(crate) const 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), 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, &guide.colourbar_for(aes));
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 let m = theme.legend_margin_outer();
286 m.top
287 + (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
288 + m.bottom
289}
290
291pub(crate) fn top_legend_lift(
308 theme: &Theme,
309 scales: &ScaleSet,
310 suppressed: &std::collections::HashSet<Aesthetic>,
311 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
312 guide: &GuideLegend,
313) -> f64 {
314 if !matches!(theme.legend_position, LegendPosition::Top) {
315 return if extra_legend_sides(scales, suppressed, guide, &theme.legend_position)[2] {
318 legend_band_height(theme, 0)
319 } else {
320 0.0
321 };
322 }
323 let legend_scales = legend_scales_for(scales, suppressed);
324 if legend_scales.is_empty() {
325 return 0.0;
326 }
327 let is_horizontal = match theme.legend_direction {
328 Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
329 None => true, };
331 let base = legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans));
332 let colorbar_extra = if is_horizontal {
333 legend_scales
334 .iter()
335 .find_map(|aes| {
336 if !matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
337 return None;
338 }
339 let scale = scales.get(aes)?;
340 if scale.is_discrete() {
341 return None;
342 }
343 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
344 let bar_height = continuous_horizontal_bar_height(
345 theme,
346 !title.is_empty(),
347 &guide.colourbar_for(aes),
348 );
349 Some((bar_height - theme.legend_key_height).max(0.0))
350 })
351 .unwrap_or(0.0)
352 } else {
353 0.0
354 };
355 base + colorbar_extra
356}
357
358fn estimate_legend_box(
371 scales: &ScaleSet,
372 legend_scales: &[Aesthetic],
373 theme: &Theme,
374 guide: &GuideLegend,
375 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
376) -> (f64, f64) {
377 let mut total_h = 0.0_f64;
378 let mut max_w = 0.0_f64;
379 for aes in legend_scales {
380 if let Some((w, h)) = estimate_guide_size(scales, aes, theme, guide, legend_plans) {
381 total_h += h;
382 max_w = max_w.max(w);
383 }
384 }
385 (max_w, total_h)
386}
387
388fn horizontal_guide_height(
391 scales: &ScaleSet,
392 aes: &Aesthetic,
393 theme: &Theme,
394 guide: &GuideLegend,
395 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
396) -> f64 {
397 let Some(scale) = scales.get(aes) else {
398 return 0.0;
399 };
400 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
401 if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
402 return continuous_horizontal_bar_height(
403 theme,
404 !title.is_empty(),
405 &guide.colourbar_for(aes),
406 );
407 }
408 let breaks = scale.breaks();
409 let title_offset = if title.is_empty() {
410 0.0
411 } else {
412 theme.legend_title.size + 4.0
413 };
414 let key = theme.legend_key_height.max(
415 theme
416 .legend_key_width
417 .max(size_key_diameter(aes, scales, scale, &breaks)),
418 );
419 let rows = legend_plans.get(aes).map_or(1, |p| p.rows.max(1)) as f64;
420 let gap = theme.legend_key_gap_y();
421 let bins_labels = if guide.bins_for(aes, scale).is_some() {
422 2.0 + theme.legend_text.size
423 } else {
424 0.0
425 };
426 title_offset + rows * (key + gap) - gap + bins_labels
427}
428
429fn estimate_guide_size(
432 scales: &ScaleSet,
433 aes: &Aesthetic,
434 theme: &Theme,
435 guide: &GuideLegend,
436 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
437) -> Option<(f64, f64)> {
438 {
439 let scale = scales.get(aes)?;
440 let breaks = scale.breaks();
441 if breaks.is_empty() {
442 return None;
443 }
444 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
445 let title_offset = if !title.is_empty() {
446 theme.legend_title.size + 4.0
447 } else {
448 0.0
449 };
450 let diameter = size_key_diameter(aes, scales, scale, &breaks);
453 let key_width = theme.legend_key_width.max(diameter);
454 let key_height = theme.legend_key_height.max(key_width);
455 let label_w = breaks
458 .iter()
459 .map(|(_, l)| crate::render::text::text_width(l, theme.legend_text.size))
460 .fold(0.0_f64, f64::max);
461 let title_w = crate::render::text::text_width(title, theme.legend_title.size);
462 let single_col_width = (key_width + theme.legend_spacing + label_w).max(title_w);
463 let is_continuous_bar =
464 matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
465 let (w, h) = if is_continuous_bar {
466 let bar = guide.colourbar_for(aes);
470 let bar_w = bar.bar_width.unwrap_or(key_width);
471 let bar_h = bar.bar_height.unwrap_or(key_height * 8.0);
472 (
473 (bar_w + theme.legend_spacing + label_w).max(title_w),
474 title_offset + bar_h + theme.legend_text.size / 2.0,
475 )
476 } else if let Some(plan) = legend_plans.get(aes) {
477 let w = if plan.col_pitch > 0.0 {
478 plan.cols as f64 * plan.col_pitch
479 } else {
480 single_col_width
481 };
482 let gap = theme.legend_key_gap_y();
483 (
484 w,
485 title_offset + plan.rows as f64 * (key_height + gap) - gap,
486 )
487 } else {
488 let gap = theme.legend_key_gap_y();
489 (
490 single_col_width,
491 title_offset + breaks.len() as f64 * (key_height + gap) - gap,
492 )
493 };
494 Some((w, h))
495 }
496}
497
498fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
504 if size >= hi - lo {
505 lo
506 } else {
507 pos.clamp(lo, hi - size)
508 }
509}
510
511pub(crate) fn legend_position(
546 theme: &Theme,
547 plot_area: &Rect,
548 legend_extra_rows: usize,
549 legend_size_hint: (f64, f64),
550 title_lift: f64,
551 canvas: &Rect,
552) -> (f64, f64, bool) {
553 let (ref_x, ref_y) = if theme.legend_location_plot {
556 (
557 canvas.x + theme.plot_margin.left,
558 canvas.y + theme.plot_margin.top,
559 )
560 } else {
561 (plot_area.x, plot_area.y)
562 };
563 let om = theme.legend_margin_outer();
565 match theme.legend_position {
566 LegendPosition::Right => (
567 plot_area.x + plot_area.width + om.left,
568 ref_y + om.top,
569 false,
570 ),
571 LegendPosition::Left => (om.left, ref_y + om.top, false),
572 LegendPosition::Top => (
573 ref_x + om.left,
574 plot_area.y - legend_band_height(theme, legend_extra_rows) + om.top,
575 true,
576 ),
577 LegendPosition::Bottom => (
578 ref_x + om.left,
579 plot_area.y
583 + plot_area.height
584 + om.top
585 + crate::render::layout::PlotLayout::x_axis_band(theme, None).min(30.0),
586 true,
587 ),
588 LegendPosition::None => (0.0, 0.0, false),
589 LegendPosition::Inside(fx, fy) => {
590 let (legend_w, legend_h) = legend_size_hint;
591 let m = &theme.legend_margin;
596 let lift = title_lift;
597 let box_h = legend_h + lift;
598 let raw_x = plot_area.x + m.left + fx * (plot_area.width - m.left - m.right - legend_w);
599 let raw_top =
602 plot_area.y + m.top + (1.0 - fy) * (plot_area.height - m.top - m.bottom - box_h);
603 let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
604 let top = clamp_span(raw_top, box_h, canvas.y, canvas.y + canvas.height);
605 (x, top + lift, false)
606 }
607 }
608}
609
610fn inside_title_lift(
614 scales: &ScaleSet,
615 representatives: &[Aesthetic],
616 theme: &Theme,
617 guide: &GuideLegend,
618) -> f64 {
619 let first = representatives
620 .iter()
621 .filter_map(|aes| scales.get(aes))
622 .find(|scale| !scale.breaks().is_empty());
623 match first {
624 Some(scale)
625 if theme.legend_title.visible
626 && !guide
627 .title
628 .as_deref()
629 .unwrap_or_else(|| scale.name())
630 .is_empty() =>
631 {
632 theme.legend_title.size / 2.0
633 }
634 _ => 0.0,
635 }
636}
637
638pub(crate) fn size_key_diameter(
648 aes: &Aesthetic,
649 scales: &ScaleSet,
650 scale: &dyn crate::scale::Scale,
651 breaks: &[(f64, String)],
652) -> f64 {
653 if *aes != Aesthetic::Size {
654 return 0.0;
655 }
656 let max_r = breaks
657 .iter()
658 .map(|(pos, label)| {
659 scale
660 .map_fraction_to_size(*pos)
661 .or_else(|| scales.map_size(&Value::Str(label.clone())))
662 .unwrap_or(3.0)
663 })
664 .fold(0.0_f64, f64::max);
665 max_r * 2.0
666}
667
668#[allow(clippy::too_many_arguments)]
670fn draw_discrete_legend_at(
680 scales: &ScaleSet,
681 group: &[(Aesthetic, &dyn crate::scale::Scale)],
682 theme: &Theme,
683 legend_x: f64,
684 legend_y: f64,
685 backend: &mut dyn DrawBackend,
686 guide: &GuideLegend,
687 is_horizontal: bool,
688) -> Result<f64, RenderError> {
689 let (_, scale) = &group[0];
690 let mut breaks = scale.breaks();
691 if breaks.is_empty() {
692 return Ok(0.0);
693 }
694
695 if guide.is_reversed(&group[0].0) {
697 breaks.reverse();
698 }
699
700 let diameter = group
708 .iter()
709 .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
710 .fold(0.0_f64, f64::max);
711 let swatch_size = theme.legend_key_width.max(diameter);
712 let item_height = theme.legend_key_height.max(swatch_size);
713
714 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
716 let legend_family = if theme.legend_title.family.is_empty() {
717 None
718 } else {
719 Some(theme.legend_title.family.clone())
720 };
721 let title_offset = if !title.is_empty() {
722 backend.set_element_class(Some("gg-legend gg-legend-title"));
723 crate::render::rich::draw(
724 backend,
725 theme.rich_text,
726 title,
727 (legend_x, legend_y),
728 &TextStyle {
729 color: theme.legend_title.color,
730 size: theme.legend_title.size,
731 anchor: TextAnchor::Start,
732 angle: 0.0,
733 family: legend_family,
734 face: theme.legend_title.face,
735 bg_colour: None,
736 bg_r: 0.0,
737 },
738 )?;
739 theme.legend_title.size + 4.0
740 } else {
741 0.0
742 };
743
744 let items_y = legend_y + title_offset;
745
746 let gap_y = theme.legend_key_gap_y();
748 let keys_height = breaks.len() as f64 * item_height + (breaks.len() - 1) as f64 * gap_y;
749
750 if theme.legend_background.visible {
753 let (total_width, total_height) = if is_horizontal {
754 let row: f64 = breaks
755 .iter()
756 .map(|(_, label)| {
757 swatch_size
758 + theme.legend_spacing
759 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
760 + theme
761 .legend_key_spacing_x
762 .unwrap_or(theme.legend_spacing * 3.0)
763 })
764 .sum();
765 (row, item_height)
766 } else {
767 (
768 swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0,
769 keys_height,
770 )
771 };
772 if let Some(fill) = theme.legend_background.fill {
773 backend.set_element_class(Some("gg-legend gg-legend-background"));
774 backend.draw_rect(
775 (legend_x - 2.0, items_y - 2.0),
776 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
777 &RectStyle {
778 fill: Some(fill),
779 stroke: theme.legend_background.color,
780 stroke_width: theme.legend_background.width,
781 alpha: 1.0,
782 clip: false,
783 corner_radius: 0.0,
784 },
785 )?;
786 }
787 }
788
789 if let Some(bins) = guide.bins_for(&group[0].0, *scale) {
792 if let Some(mut edges) = scale
793 .bin_edge_labels()
794 .filter(|e| e.len() == breaks.len() + 1)
795 {
796 if guide.is_reversed(&group[0].0) {
797 edges.reverse();
798 }
799 let extent = draw_bins_keys(
800 scales,
801 group,
802 theme,
803 guide,
804 &bins,
805 &breaks,
806 &edges,
807 (legend_x, items_y),
808 swatch_size,
809 item_height,
810 backend,
811 is_horizontal,
812 )?;
813 return Ok(if is_horizontal {
814 extent
815 } else {
816 title_offset + extent
817 });
818 }
819 }
820
821 let mut cur_x = legend_x;
823 for (i, (pos, label)) in breaks.iter().enumerate() {
824 let x = if is_horizontal { cur_x } else { legend_x };
825 let y = if is_horizontal {
826 items_y
827 } else {
828 items_y + i as f64 * (item_height + gap_y)
829 };
830 let center_x = x + swatch_size / 2.0;
831 let center_y = y + swatch_size / 2.0;
832
833 if theme.legend_key.visible {
835 if let Some(fill) = theme.legend_key.fill {
836 backend.set_element_class(Some("gg-legend gg-legend-key"));
837 backend.draw_rect(
838 (x, y),
839 (x + swatch_size, y + swatch_size),
840 &RectStyle {
841 fill: Some(fill),
842 stroke: theme.legend_key.color,
843 stroke_width: theme.legend_key.width,
844 alpha: 1.0,
845 clip: false,
846 corner_radius: 0.0,
847 },
848 )?;
849 }
850 }
851
852 backend.set_element_class(Some("gg-legend gg-legend-key"));
856 draw_group_keys(
857 backend,
858 scales,
859 group,
860 theme,
861 guide,
862 label,
863 *pos,
864 (x, y, center_x, center_y),
865 swatch_size,
866 )?;
867
868 let label_family = if theme.legend_text.family.is_empty() {
870 None
871 } else {
872 Some(theme.legend_text.family.clone())
873 };
874 backend.set_element_class(Some("gg-legend gg-legend-text"));
875 crate::render::rich::draw(
876 backend,
877 theme.rich_text,
878 label,
879 (x + swatch_size + theme.legend_spacing, center_y),
880 &TextStyle {
881 color: theme.legend_text.color,
882 size: theme.legend_text.size,
883 anchor: TextAnchor::Start,
884 angle: 0.0,
885 family: label_family,
886 face: theme.legend_text.face,
887 bg_colour: None,
888 bg_r: 0.0,
889 },
890 )?;
891 if is_horizontal {
892 cur_x = x
894 + swatch_size
895 + theme.legend_spacing
896 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
897 + theme
898 .legend_key_spacing_x
899 .unwrap_or(theme.legend_spacing * 3.0);
900 }
901 }
902
903 if is_horizontal {
904 Ok(cur_x - legend_x) } else {
906 Ok(title_offset + keys_height)
907 }
908}
909
910#[allow(clippy::too_many_arguments)]
918fn draw_bins_keys(
919 scales: &ScaleSet,
920 group: &[(Aesthetic, &dyn crate::scale::Scale)],
921 theme: &Theme,
922 guide: &GuideLegend,
923 bins: &crate::guide::config::GuideBins,
924 breaks: &[(f64, String)],
925 edges: &[String],
926 (x0, y0): (f64, f64),
927 swatch_size: f64,
928 item_height: f64,
929 backend: &mut dyn DrawBackend,
930 is_horizontal: bool,
931) -> Result<f64, RenderError> {
932 let n = breaks.len();
933 let text_size = theme.legend_text.size;
934 let pitch = if is_horizontal {
936 let widest = edges
937 .iter()
938 .map(|l| l.chars().count() as f64 * text_size * 0.62)
939 .fold(0.0_f64, f64::max);
940 swatch_size.max(widest + theme.legend_spacing)
941 } else {
942 item_height
943 };
944 for (i, (pos, label)) in breaks.iter().enumerate() {
945 let (cell_x, cell_y) = if is_horizontal {
946 (x0 + i as f64 * pitch, y0)
947 } else {
948 (x0, y0 + i as f64 * pitch)
949 };
950 let (cell_w, cell_h) = if is_horizontal {
951 (pitch, swatch_size)
952 } else {
953 (swatch_size, pitch)
954 };
955 if theme.legend_key.visible {
956 if let Some(fill) = theme.legend_key.fill {
957 backend.set_element_class(Some("gg-legend gg-legend-key"));
958 backend.draw_rect(
959 (cell_x, cell_y),
960 (cell_x + cell_w, cell_y + cell_h),
961 &RectStyle {
962 fill: Some(fill),
963 stroke: theme.legend_key.color,
964 stroke_width: theme.legend_key.width,
965 alpha: 1.0,
966 clip: false,
967 corner_radius: 0.0,
968 },
969 )?;
970 }
971 }
972 let x = cell_x + (cell_w - swatch_size) / 2.0;
974 let y = cell_y + (cell_h - swatch_size) / 2.0;
975 backend.set_element_class(Some("gg-legend gg-legend-key"));
976 draw_group_keys(
977 backend,
978 scales,
979 group,
980 theme,
981 guide,
982 label,
983 *pos,
984 (x, y, x + swatch_size / 2.0, y + swatch_size / 2.0),
985 swatch_size,
986 )?;
987 }
988
989 let length = n as f64 * pitch;
990 let tick = (swatch_size * 0.2).max(2.0);
991 let line_style = LineStyle {
992 color: theme.legend_text.color,
993 width: 0.5,
994 alpha: 1.0,
995 linetype: Linetype::Solid,
996 ..Default::default()
997 };
998 if bins.axis {
999 let axis = if is_horizontal {
1000 let ay = y0 + swatch_size;
1001 [(x0, ay), (x0 + length, ay)]
1002 } else {
1003 let ax = x0 + swatch_size;
1004 [(ax, y0), (ax, y0 + length)]
1005 };
1006 backend.set_element_class(Some("gg-legend gg-legend-tick"));
1007 backend.draw_line(&axis, &line_style)?;
1008 }
1009 let family = if theme.legend_text.family.is_empty() {
1010 None
1011 } else {
1012 Some(theme.legend_text.family.clone())
1013 };
1014 for (j, label) in edges.iter().enumerate() {
1015 let at = j as f64 * pitch;
1016 if bins.axis {
1017 let t = if is_horizontal {
1018 let (x, ay) = (x0 + at, y0 + swatch_size);
1019 [(x, ay), (x, ay - tick)]
1020 } else {
1021 let (ax, y) = (x0 + swatch_size, y0 + at);
1022 [(ax, y), (ax - tick, y)]
1023 };
1024 backend.set_element_class(Some("gg-legend gg-legend-tick"));
1025 backend.draw_line(&t, &line_style)?;
1026 }
1027 if (j == 0 || j == n) && !bins.show_limits {
1028 continue;
1029 }
1030 let (pos, anchor) = if is_horizontal {
1031 (
1032 (x0 + at, y0 + swatch_size + 2.0 + text_size * 0.6),
1033 TextAnchor::Middle,
1034 )
1035 } else {
1036 (
1037 (x0 + swatch_size + theme.legend_spacing, y0 + at),
1038 TextAnchor::Start,
1039 )
1040 };
1041 backend.set_element_class(Some("gg-legend gg-legend-text"));
1042 crate::render::rich::draw(
1043 backend,
1044 theme.rich_text,
1045 label,
1046 pos,
1047 &TextStyle {
1048 color: theme.legend_text.color,
1049 size: text_size,
1050 anchor,
1051 angle: 0.0,
1052 family: family.clone(),
1053 face: theme.legend_text.face,
1054 bg_colour: None,
1055 bg_r: 0.0,
1056 },
1057 )?;
1058 }
1059 Ok(length)
1060}
1061
1062pub(crate) fn continuous_bar_dims(
1072 theme: &Theme,
1073 is_horizontal: bool,
1074 bar: &GuideColourbar,
1075) -> (f64, f64) {
1076 let (w, h) = if is_horizontal {
1077 (theme.legend_key_height * 8.0, theme.legend_key_width)
1078 } else {
1079 (theme.legend_key_width, theme.legend_key_height * 8.0)
1080 };
1081 (
1082 bar.bar_width
1083 .filter(|v| v.is_finite() && *v > 0.0)
1084 .unwrap_or(w),
1085 bar.bar_height
1086 .filter(|v| v.is_finite() && *v > 0.0)
1087 .unwrap_or(h),
1088 )
1089}
1090
1091pub(crate) fn continuous_horizontal_bar_height(
1098 theme: &Theme,
1099 has_title: bool,
1100 bar: &GuideColourbar,
1101) -> f64 {
1102 let title_offset = if has_title {
1103 theme.legend_title.size + 4.0
1104 } else {
1105 0.0
1106 };
1107 let (_, bar_thickness) = continuous_bar_dims(theme, true, bar);
1108 let tick_len = theme.legend_ticks_length;
1109 let label_line = theme.legend_text.size + 4.0;
1110 title_offset + bar_thickness + tick_len + theme.legend_spacing + label_line
1111}
1112
1113fn draw_continuous_legend_at(
1120 scale: &dyn crate::scale::Scale,
1121 theme: &Theme,
1122 legend_x: f64,
1123 legend_y: f64,
1124 backend: &mut dyn DrawBackend,
1125 guide: &GuideLegend,
1126 is_horizontal: bool,
1127) -> Result<f64, RenderError> {
1128 let breaks = scale.breaks();
1129 if breaks.is_empty() {
1130 return Ok(0.0);
1131 }
1132
1133 let bar = guide.colourbar_for(&scale.aesthetic());
1134 let (bar_width, bar_height) = continuous_bar_dims(theme, is_horizontal, &bar);
1135
1136 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1138 let cont_family = if theme.legend_title.family.is_empty() {
1139 None
1140 } else {
1141 Some(theme.legend_title.family.clone())
1142 };
1143 let title_offset = if !title.is_empty() {
1144 backend.set_element_class(Some("gg-legend gg-legend-title"));
1145 crate::render::rich::draw(
1146 backend,
1147 theme.rich_text,
1148 title,
1149 (legend_x, legend_y),
1150 &TextStyle {
1151 color: theme.legend_title.color,
1152 size: theme.legend_title.size,
1153 anchor: TextAnchor::Start,
1154 angle: 0.0,
1155 family: cont_family,
1156 face: theme.legend_title.face,
1157 bg_colour: None,
1158 bg_r: 0.0,
1159 },
1160 )?;
1161 theme.legend_title.size + 4.0
1162 } else {
1163 0.0
1164 };
1165
1166 let bar_top = legend_y + title_offset;
1167
1168 if theme.legend_background.visible {
1170 let total_width = if is_horizontal {
1171 bar_width
1172 } else {
1173 bar_width + theme.legend_spacing + theme.legend_text.size * 6.0
1174 };
1175 let total_height = if is_horizontal {
1176 bar_height + theme.legend_spacing + theme.legend_text.size * 2.0
1177 } else {
1178 bar_height
1179 };
1180 if let Some(fill) = theme.legend_background.fill {
1181 backend.set_element_class(Some("gg-legend gg-legend-background"));
1182 backend.draw_rect(
1183 (legend_x - 2.0, bar_top - 2.0),
1184 (legend_x + total_width + 2.0, bar_top + total_height + 2.0),
1185 &RectStyle {
1186 fill: Some(fill),
1187 stroke: theme.legend_background.color,
1188 stroke_width: theme.legend_background.width,
1189 alpha: 1.0,
1190 clip: false,
1191 corner_radius: 0.0,
1192 },
1193 )?;
1194 }
1195 }
1196
1197 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
1201 let at = |t: f64| data_min + t * (data_max - data_min);
1202 let mut step_labels: Option<Vec<(f64, String)>> = None;
1205 if !bar.steps && bar.display == ColourbarDisplay::Gradient {
1206 let n_slices = bar.nbin.max(1);
1209 if is_horizontal {
1210 let slice_width = bar_width / n_slices as f64;
1211 for i in 0..n_slices {
1212 let t = i as f64 / n_slices as f64;
1213 let color = scale
1214 .map_to_color(&Value::Float(at(t)))
1215 .unwrap_or((127, 127, 127));
1216 let sx = legend_x + i as f64 * slice_width;
1217 backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
1218 backend.draw_rect(
1219 (sx, bar_top),
1220 (sx + slice_width + 0.5, bar_top + bar_height),
1221 &RectStyle {
1222 fill: Some(color),
1223 stroke: None,
1224 stroke_width: 0.0,
1225 alpha: 1.0,
1226 clip: false,
1227 corner_radius: 0.0,
1228 },
1229 )?;
1230 }
1231 } else {
1232 let slice_height = bar_height / n_slices as f64;
1233 for i in 0..n_slices {
1234 let t = 1.0 - i as f64 / n_slices as f64;
1235 let color = scale
1236 .map_to_color(&Value::Float(at(t)))
1237 .unwrap_or((127, 127, 127));
1238 let sy = bar_top + i as f64 * slice_height;
1239 backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
1240 backend.draw_rect(
1241 (legend_x, sy),
1242 (legend_x + bar_width, sy + slice_height + 0.5),
1243 &RectStyle {
1244 fill: Some(color),
1245 stroke: None,
1246 stroke_width: 0.0,
1247 alpha: 1.0,
1248 clip: false,
1249 corner_radius: 0.0,
1250 },
1251 )?;
1252 }
1253 }
1254 } else {
1255 let blocks: Vec<(f64, f64, f64)> = if bar.steps {
1259 let mut edges: Vec<(f64, Option<String>)> = vec![(0.0, None)];
1260 for (pos, label) in &breaks {
1261 if *pos > 1e-9 && *pos < 1.0 - 1e-9 {
1262 edges.push((*pos, Some(label.clone())));
1263 }
1264 }
1265 edges.push((1.0, None));
1266 let n = edges.len() - 1;
1267 let place = |i: usize| {
1268 if bar.even_steps {
1269 i as f64 / n as f64
1270 } else {
1271 edges[i].0
1272 }
1273 };
1274 let mut labels: Vec<(f64, String)> = Vec::new();
1275 for (i, (_, l)) in edges.iter().enumerate() {
1276 match l {
1277 Some(l) => labels.push((place(i), l.clone())),
1278 None if bar.show_limits => {
1279 let v = if i == 0 { data_min } else { data_max };
1280 labels.push((place(i), crate::scale::util::format_number_opt(v, None)));
1281 }
1282 None => {}
1283 }
1284 }
1285 step_labels = Some(labels);
1286 (0..n)
1287 .map(|i| {
1288 (
1289 place(i),
1290 place(i + 1),
1291 at((edges[i].0 + edges[i + 1].0) / 2.0),
1292 )
1293 })
1294 .collect()
1295 } else {
1296 let n = bar.nbin.max(1);
1297 (0..n)
1298 .map(|i| {
1299 let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);
1300 (t0, t1, at((t0 + t1) / 2.0))
1301 })
1302 .collect()
1303 };
1304 for &(t0, t1, value) in &blocks {
1305 let color = scale
1306 .map_to_color(&Value::Float(value))
1307 .unwrap_or((127, 127, 127));
1308 let (p0, p1) = if is_horizontal {
1309 (
1310 (legend_x + t0 * bar_width, bar_top),
1311 (legend_x + t1 * bar_width, bar_top + bar_height),
1312 )
1313 } else {
1314 (
1315 (legend_x, bar_top + (1.0 - t1) * bar_height),
1316 (legend_x + bar_width, bar_top + (1.0 - t0) * bar_height),
1317 )
1318 };
1319 backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
1320 backend.draw_rect(
1321 p0,
1322 p1,
1323 &RectStyle {
1324 fill: Some(color),
1325 stroke: None,
1326 stroke_width: 0.0,
1327 alpha: 1.0,
1328 clip: false,
1329 corner_radius: 0.0,
1330 },
1331 )?;
1332 }
1333 }
1334
1335 let border = theme.legend_bar_border_line();
1338 let border_style = LineStyle {
1339 color: border.color,
1340 width: border.width,
1341 alpha: 1.0,
1342 linetype: border.linetype,
1343 ..Default::default()
1344 };
1345 if bar.frame.unwrap_or(border.visible) {
1346 backend.set_element_class(Some("gg-legend gg-colourbar-frame"));
1347 backend.draw_line(
1348 &[
1349 (legend_x, bar_top),
1350 (legend_x + bar_width, bar_top),
1351 (legend_x + bar_width, bar_top + bar_height),
1352 (legend_x, bar_top + bar_height),
1353 (legend_x, bar_top),
1354 ],
1355 &border_style,
1356 )?;
1357 }
1358
1359 let tick_len = theme.legend_ticks_length;
1363 let tick_family = if theme.legend_text.family.is_empty() {
1364 None
1365 } else {
1366 Some(theme.legend_text.family.clone())
1367 };
1368 let labels = step_labels.unwrap_or(breaks);
1369 for (pos, label) in &labels {
1370 if is_horizontal {
1371 let tick_x = legend_x + bar_width * pos;
1372 if bar.ticks {
1373 backend.set_element_class(Some("gg-legend gg-legend-tick"));
1374 backend.draw_line(
1375 &[
1376 (tick_x, bar_top + bar_height),
1377 (tick_x, bar_top + bar_height + tick_len),
1378 ],
1379 &border_style,
1380 )?;
1381 }
1382 backend.set_element_class(Some("gg-legend gg-legend-text"));
1383 crate::render::rich::draw(
1384 backend,
1385 theme.rich_text,
1386 label,
1387 (
1388 tick_x,
1389 bar_top + bar_height + tick_len + theme.legend_text.size,
1390 ),
1391 &TextStyle {
1392 color: theme.legend_text.color,
1393 size: theme.legend_text.size,
1394 anchor: TextAnchor::Middle,
1395 angle: 0.0,
1396 family: tick_family.clone(),
1397 face: theme.legend_text.face,
1398 bg_colour: None,
1399 bg_r: 0.0,
1400 },
1401 )?;
1402 } else {
1403 let tick_y = bar_top + bar_height * (1.0 - pos);
1404 if bar.ticks {
1405 backend.set_element_class(Some("gg-legend gg-legend-tick"));
1406 backend.draw_line(
1407 &[
1408 (legend_x + bar_width, tick_y),
1409 (legend_x + bar_width + tick_len, tick_y),
1410 ],
1411 &border_style,
1412 )?;
1413 }
1414 backend.set_element_class(Some("gg-legend gg-legend-text"));
1415 crate::render::rich::draw(
1416 backend,
1417 theme.rich_text,
1418 label,
1419 (
1420 legend_x + bar_width + tick_len + theme.legend_spacing,
1421 tick_y,
1422 ),
1423 &TextStyle {
1424 color: theme.legend_text.color,
1425 size: theme.legend_text.size,
1426 anchor: TextAnchor::Start,
1427 angle: 0.0,
1428 family: tick_family.clone(),
1429 face: theme.legend_text.face,
1430 bg_colour: None,
1431 bg_r: 0.0,
1432 },
1433 )?;
1434 }
1435 }
1436
1437 if is_horizontal {
1438 Ok(continuous_horizontal_bar_height(
1439 theme,
1440 !title.is_empty(),
1441 &bar,
1442 ))
1443 } else {
1444 Ok(title_offset + bar_height)
1445 }
1446}
1447
1448#[allow(clippy::too_many_arguments)]
1470fn draw_legend_swatch(
1471 backend: &mut dyn DrawBackend,
1472 scales: &ScaleSet,
1473 aes: &Aesthetic,
1474 scale: &dyn crate::scale::Scale,
1475 theme: &Theme,
1476 label: &str,
1477 pos: f64,
1478 x: f64,
1479 y: f64,
1480 center_x: f64,
1481 center_y: f64,
1482 swatch_size: f64,
1483) -> Result<(), RenderError> {
1484 let value = Value::Str(label.to_string());
1485 match aes {
1486 Aesthetic::Color | Aesthetic::Fill => {
1487 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
1488 let style = RectStyle {
1489 fill: Some(color),
1490 stroke: None,
1491 stroke_width: 0.0,
1492 alpha: 1.0,
1493 clip: false,
1494 corner_radius: 0.0,
1495 };
1496 if scale.is_na_break(label) {
1499 if let Some(p) = scale.na_fill_pattern() {
1500 return backend.draw_rect_pattern(
1501 (x, y),
1502 (x + swatch_size, y + swatch_size),
1503 &p,
1504 &style,
1505 );
1506 }
1507 }
1508 backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
1509 }
1510 Aesthetic::Shape => {
1511 let shape = scales
1512 .map_shape(&value)
1513 .unwrap_or(crate::render::backend::PointShape::Circle);
1514 backend.draw_shape(
1515 (center_x, center_y),
1516 swatch_size / 3.0,
1517 &PointStyle {
1518 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1519 alpha: 1.0,
1520 filled: true,
1521 shape,
1522 },
1523 )?;
1524 }
1525 Aesthetic::Linetype => {
1526 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
1527 backend.draw_line(
1528 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1529 &LineStyle {
1530 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1531 width: 1.5,
1532 alpha: 1.0,
1533 linetype: lt,
1534 ..Default::default()
1535 },
1536 )?;
1537 }
1538 Aesthetic::Size => {
1539 let size = scale
1540 .map_fraction_to_size(pos)
1541 .or_else(|| scales.map_size(&value))
1542 .unwrap_or(3.0);
1543 backend.draw_shape(
1544 (center_x, center_y),
1545 size.min(swatch_size / 2.0),
1546 &PointStyle {
1547 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1548 alpha: 1.0,
1549 filled: true,
1550 shape: crate::render::backend::PointShape::Circle,
1551 },
1552 )?;
1553 }
1554 Aesthetic::Alpha => {
1555 let alpha = scale
1556 .map_fraction_to_alpha(pos)
1557 .or_else(|| scales.map_alpha(&value))
1558 .unwrap_or(1.0);
1559 backend.draw_rect(
1560 (x, y),
1561 (x + swatch_size, y + swatch_size),
1562 &RectStyle {
1563 fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
1564 stroke: None,
1565 stroke_width: 0.0,
1566 alpha,
1567 clip: false,
1568 corner_radius: 0.0,
1569 },
1570 )?;
1571 }
1572 Aesthetic::Linewidth => {
1573 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
1574 backend.draw_line(
1575 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
1576 &LineStyle {
1577 color: theme.geom.ink.unwrap_or(theme.legend_text.color),
1578 width: width.min(swatch_size),
1579 alpha: 1.0,
1580 linetype: Linetype::Solid,
1581 ..Default::default()
1582 },
1583 )?;
1584 }
1585 _ => {}
1586 }
1587 Ok(())
1588}
1589
1590#[allow(clippy::too_many_arguments)]
1596fn draw_group_keys(
1597 backend: &mut dyn DrawBackend,
1598 scales: &ScaleSet,
1599 group: &[(Aesthetic, &dyn crate::scale::Scale)],
1600 theme: &Theme,
1601 guide: &GuideLegend,
1602 label: &str,
1603 pos: f64,
1604 (x, y, center_x, center_y): (f64, f64, f64, f64),
1605 swatch_size: f64,
1606) -> Result<(), RenderError> {
1607 use crate::guide::key::{draw_key, KeyProps};
1608 let in_group = |a: &Aesthetic| group.iter().any(|(g, _)| g == a);
1609 let layers: Vec<_> = guide
1610 .layer_keys
1611 .iter()
1612 .filter(|k| k.aesthetics.iter().any(in_group))
1613 .collect();
1614 let legacy = |backend: &mut dyn DrawBackend, only: Option<&[Aesthetic]>| {
1615 for (member_aes, member_scale) in group {
1616 if only.is_some_and(|o| !o.contains(member_aes)) {
1617 continue;
1618 }
1619 draw_legend_swatch(
1620 backend,
1621 scales,
1622 member_aes,
1623 *member_scale,
1624 theme,
1625 label,
1626 pos,
1627 x,
1628 y,
1629 center_x,
1630 center_y,
1631 swatch_size,
1632 )?;
1633 }
1634 Ok::<(), RenderError>(())
1635 };
1636 if layers.iter().all(|k| k.glyph.is_none()) {
1637 return legacy(backend, None);
1638 }
1639 let value = Value::Str(label.to_string());
1640 let ink = theme.geom.ink.unwrap_or(theme.legend_text.color);
1641 for k in layers {
1642 let Some(glyph) = k.glyph else {
1643 legacy(backend, Some(&k.aesthetics))?;
1644 continue;
1645 };
1646 let mut props = KeyProps::default();
1647 for (aes, scale) in group {
1648 if !k.aesthetics.contains(aes) {
1649 continue;
1650 }
1651 match aes {
1652 Aesthetic::Color => props.color = scales.map_color(aes, &value),
1653 Aesthetic::Fill => props.fill = scales.map_color(aes, &value),
1654 Aesthetic::Shape => props.shape = scales.map_shape(&value),
1655 Aesthetic::Linetype => props.linetype = scales.map_linetype(&value),
1656 Aesthetic::Size => {
1657 props.size = scale
1658 .map_fraction_to_size(pos)
1659 .or_else(|| scales.map_size(&value))
1660 }
1661 Aesthetic::Alpha => {
1662 props.alpha = scale
1663 .map_fraction_to_alpha(pos)
1664 .or_else(|| scales.map_alpha(&value))
1665 }
1666 Aesthetic::Linewidth => props.linewidth = scale.map_fraction_to_size(pos),
1667 _ => {}
1668 }
1669 }
1670 for (aes, _) in group {
1672 if let Some((_, o)) = guide.overrides.iter().find(|(a, _)| a == aes) {
1673 props.color = o.color.or(props.color);
1674 props.fill = o.fill.or(props.fill);
1675 props.shape = o.shape.or(props.shape);
1676 props.linetype = o.linetype.clone().or(props.linetype);
1677 props.size = o.size.or(props.size);
1678 props.alpha = o.alpha.or(props.alpha);
1679 props.linewidth = o.linewidth.or(props.linewidth);
1680 }
1681 }
1682 draw_key(backend, glyph, &props, ink, (x, y, swatch_size))?;
1683 }
1684 Ok(())
1685}
1686
1687#[derive(Clone, Debug)]
1693pub(crate) struct LegendEntry {
1694 pub break_idx: usize,
1695 pub row: usize,
1696 pub x_offset: f64,
1697 pub label_override: Option<String>,
1704}
1705
1706#[derive(Clone, Debug)]
1710pub(crate) struct LegendOverflow {
1711 pub dropped: usize,
1712 pub row: usize,
1713 pub x_offset: f64,
1714 pub text: String,
1715}
1716
1717#[derive(Clone, Debug)]
1722pub(crate) struct LegendPlan {
1723 pub rows: usize,
1724 pub entries: Vec<LegendEntry>,
1725 pub overflow: Option<LegendOverflow>,
1726 pub cols: usize,
1731 pub col_pitch: f64,
1734}
1735
1736fn legend_pitch(label: &str, theme: &Theme) -> f64 {
1741 let swatch = theme.legend_key_width;
1742 let spacing = theme.legend_spacing;
1743 let text_size = theme.legend_text.size;
1744 let gap = theme.legend_key_spacing_x;
1746 let legacy = swatch
1747 + spacing
1748 + (label.chars().count() as f64) * text_size * 0.62
1749 + gap.unwrap_or(spacing * 3.0);
1750 let measured =
1751 swatch + spacing + crate::render::text::text_width(label, text_size) + gap.unwrap_or(8.0);
1752 legacy.max(measured)
1753}
1754
1755pub(crate) fn plan_legend(
1763 breaks: &[(f64, String)],
1764 guide: &GuideLegend,
1765 theme: &Theme,
1766 avail_width: f64,
1767 avail_height: f64,
1768 is_horizontal: bool,
1769) -> Option<LegendPlan> {
1770 let n = breaks.len();
1771 if n == 0 {
1772 return None;
1773 }
1774
1775 if guide.nrow.is_some() || guide.ncol.is_some() {
1777 let (rows, cols) = match (guide.nrow, guide.ncol) {
1778 (Some(nrow), Some(ncol)) => (nrow, ncol),
1779 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
1780 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
1781 (None, None) => unreachable!("checked above"),
1782 };
1783 let rows = rows.max(1);
1784 let cols = cols.max(1);
1785 let col_pitch = breaks
1786 .iter()
1787 .map(|(_, l)| legend_pitch(l, theme))
1788 .fold(0.0_f64, f64::max);
1789 let entries = (0..n)
1790 .map(|i| {
1791 let (row, col) = if guide.byrow {
1792 (i / cols, i % cols)
1793 } else {
1794 (i % rows, i / rows)
1795 };
1796 LegendEntry {
1797 break_idx: i,
1798 row,
1799 x_offset: col as f64 * col_pitch,
1800 label_override: None,
1801 }
1802 })
1803 .collect();
1804 return Some(LegendPlan {
1805 rows,
1806 entries,
1807 overflow: None,
1808 cols,
1809 col_pitch,
1810 });
1811 }
1812
1813 if !is_horizontal {
1815 return None;
1816 }
1817
1818 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
1819 let total_width: f64 = pitches.iter().sum();
1820 if total_width <= avail_width.max(0.0) {
1821 return None; }
1823
1824 let item_height = theme.legend_key_height.max(1.0);
1825 let row_limit = guide
1826 .max_rows
1827 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
1828 .max(1);
1829
1830 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
1831 let mut row = 0usize;
1832 let mut cur_x = 0.0_f64;
1833 for (i, &p) in pitches.iter().enumerate() {
1834 if cur_x > 0.0 && cur_x + p > avail_width {
1835 if row + 1 >= row_limit {
1836 break; }
1838 row += 1;
1839 cur_x = 0.0;
1840 }
1841 entries.push(LegendEntry {
1842 break_idx: i,
1843 row,
1844 x_offset: cur_x,
1845 label_override: None,
1846 });
1847 cur_x += p;
1848 }
1849
1850 if entries.len() >= n {
1851 return Some(LegendPlan {
1852 rows: row + 1,
1853 entries,
1854 overflow: None,
1855 cols: 1,
1856 col_pitch: 0.0,
1857 });
1858 }
1859
1860 let mut dropped = n - entries.len();
1863 loop {
1864 let text = format!("+{dropped} more");
1865 let more_pitch = legend_pitch(&text, theme);
1866 let row_count = entries.iter().filter(|e| e.row == row).count();
1867 let last_x = entries
1868 .iter()
1869 .filter(|e| e.row == row)
1870 .map(|e| e.x_offset + pitches[e.break_idx])
1871 .fold(0.0_f64, f64::max);
1872 if last_x + more_pitch <= avail_width || row_count == 0 {
1873 return Some(LegendPlan {
1874 rows: row + 1,
1875 entries,
1876 overflow: Some(LegendOverflow {
1877 dropped,
1878 row,
1879 x_offset: last_x,
1880 text,
1881 }),
1882 cols: 1,
1883 col_pitch: 0.0,
1884 });
1885 }
1886 entries.pop();
1887 dropped += 1;
1888 }
1889}
1890
1891pub(crate) fn is_colorbar_aesthetic(aes: &Aesthetic) -> bool {
1902 matches!(aes, Aesthetic::Color | Aesthetic::Fill)
1903}
1904
1905fn wrap_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
1911 legend_scales
1912 .iter()
1913 .filter(|aes| {
1914 let is_discrete = scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false);
1915 is_discrete || !is_colorbar_aesthetic(aes)
1916 })
1917 .count()
1918 .max(1)
1919}
1920
1921fn colorbar_guide_width(theme: &Theme, bar: &GuideColourbar) -> f64 {
1926 let (bar_len, _) = continuous_bar_dims(theme, true, bar);
1931 bar_len + theme.legend_spacing * 2.0
1932}
1933
1934fn colorbar_guides_width(
1942 scales: &ScaleSet,
1943 legend_scales: &[Aesthetic],
1944 theme: &Theme,
1945 guide: &GuideLegend,
1946) -> f64 {
1947 legend_scales
1948 .iter()
1949 .filter(|aes| {
1950 is_colorbar_aesthetic(aes) && !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false)
1951 })
1952 .map(|aes| colorbar_guide_width(theme, &guide.colourbar_for(aes)))
1953 .sum()
1954}
1955
1956#[allow(clippy::too_many_arguments)]
1974pub(crate) fn plan_all_legends(
1975 scales: &ScaleSet,
1976 guide: &GuideLegend,
1977 theme: &Theme,
1978 suppressed: &std::collections::HashSet<Aesthetic>,
1979 avail_width: f64,
1980 avail_height: f64,
1981 is_horizontal: bool,
1982 warnings: &mut Vec<String>,
1983) -> std::collections::HashMap<Aesthetic, LegendPlan> {
1984 let legend_scales = legend_scales_for(scales, suppressed);
1985 let n_wrap = wrap_guide_count(scales, &legend_scales);
1986 let per_guide_width = if is_horizontal {
1987 let colorbar_width = colorbar_guides_width(scales, &legend_scales, theme, guide);
1996 ((avail_width - colorbar_width) / n_wrap as f64).max(0.0)
1997 } else {
1998 avail_width
1999 };
2000
2001 let mut plans = std::collections::HashMap::new();
2002 for aes in &legend_scales {
2003 let Some(scale) = scales.get(aes) else {
2004 continue;
2005 };
2006 if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
2011 continue;
2012 }
2013 if guide.bins_for(aes, scale).is_some() {
2015 continue;
2016 }
2017 let breaks = scale.breaks();
2018 let validated_guide = guide.validated(breaks.len(), warnings);
2019 let layout_breaks = scale.breaks_for_layout();
2025 if let Some(plan) = plan_legend(
2026 &layout_breaks,
2027 &validated_guide,
2028 theme,
2029 per_guide_width,
2030 avail_height,
2031 is_horizontal,
2032 ) {
2033 plans.insert(aes.clone(), plan);
2034 }
2035 }
2036 plans
2037}
2038
2039pub(crate) fn inside_legend_box(
2060 scales: &ScaleSet,
2061 theme: &Theme,
2062 plot_area: &Rect,
2063 canvas: &Rect,
2064 guide: &GuideLegend,
2065 suppressed: &std::collections::HashSet<Aesthetic>,
2066 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2067) -> Option<Rect> {
2068 if !matches!(theme.legend_position, LegendPosition::Inside(..)) {
2069 return None;
2070 }
2071 let legend_scales = legend_scales_for(scales, suppressed);
2072 if legend_scales.is_empty() {
2073 return None;
2074 }
2075 let groups = group_mergeable_legends(scales, &legend_scales, guide);
2076 let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2077 let legend_size_hint =
2078 estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
2079 let lift = inside_title_lift(scales, &representatives, theme, guide);
2080 let (legend_x, legend_y, _) =
2081 legend_position(theme, plot_area, 0, legend_size_hint, lift, canvas);
2082 Some(Rect {
2084 x: legend_x,
2085 y: legend_y - lift,
2086 width: legend_size_hint.0,
2087 height: legend_size_hint.1 + lift,
2088 })
2089}
2090
2091pub(crate) fn extra_legend_sides(
2094 scales: &ScaleSet,
2095 suppressed: &std::collections::HashSet<Aesthetic>,
2096 guide: &GuideLegend,
2097 main: &LegendPosition,
2098) -> [bool; 4] {
2099 let mut sides = [false; 4];
2100 if guide.positions.is_empty() || matches!(main, LegendPosition::None) {
2101 return sides;
2102 }
2103 for aes in legend_scales_for(scales, suppressed) {
2104 match guide.position_for(&aes, main) {
2105 p if p == main => {}
2106 LegendPosition::Right => sides[0] = true,
2107 LegendPosition::Left => sides[1] = true,
2108 LegendPosition::Top => sides[2] = true,
2109 LegendPosition::Bottom => sides[3] = true,
2110 LegendPosition::None | LegendPosition::Inside(..) => {}
2111 }
2112 }
2113 sides
2114}
2115
2116#[allow(clippy::too_many_arguments)]
2120pub(crate) fn draw_legend_planned(
2121 scales: &ScaleSet,
2122 theme: &Theme,
2123 plot_area: &Rect,
2124 legend_avail_width: f64,
2125 backend: &mut dyn DrawBackend,
2126 guide: &GuideLegend,
2127 suppressed: &std::collections::HashSet<Aesthetic>,
2128 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2129) -> Result<(), RenderError> {
2130 let main = &theme.legend_position;
2131 let drawn = legend_scales_for(scales, suppressed);
2132 let mut others: Vec<LegendPosition> = Vec::new();
2133 if !matches!(main, LegendPosition::None) {
2134 for aes in &drawn {
2135 let p = guide.position_for(aes, main);
2136 if p != main && !matches!(p, LegendPosition::None) && !others.contains(p) {
2137 others.push(p.clone());
2138 }
2139 }
2140 }
2141 if others.is_empty() {
2142 return draw_legend_side(
2143 scales,
2144 theme,
2145 plot_area,
2146 legend_avail_width,
2147 backend,
2148 guide,
2149 suppressed,
2150 legend_plans,
2151 );
2152 }
2153 let without = |at: &LegendPosition| -> std::collections::HashSet<Aesthetic> {
2155 let mut s = suppressed.clone();
2156 s.extend(
2157 drawn
2158 .iter()
2159 .filter(|a| guide.position_for(a, main) != at)
2160 .cloned(),
2161 );
2162 s
2163 };
2164 draw_legend_side(
2165 scales,
2166 theme,
2167 plot_area,
2168 legend_avail_width,
2169 backend,
2170 guide,
2171 &without(main),
2172 legend_plans,
2173 )?;
2174 let moved_guide = GuideLegend {
2175 custom: Vec::new(),
2176 ..guide.clone()
2177 };
2178 for at in &others {
2179 let side_theme = Theme {
2180 legend_position: at.clone(),
2181 ..theme.clone()
2182 };
2183 draw_legend_side(
2184 scales,
2185 &side_theme,
2186 plot_area,
2187 legend_avail_width,
2188 backend,
2189 &moved_guide,
2190 &without(at),
2191 legend_plans,
2192 )?;
2193 }
2194 Ok(())
2195}
2196
2197#[allow(clippy::too_many_arguments)]
2199fn draw_legend_side(
2200 scales: &ScaleSet,
2201 theme: &Theme,
2202 plot_area: &Rect,
2203 legend_avail_width: f64,
2213 backend: &mut dyn DrawBackend,
2214 guide: &GuideLegend,
2215 suppressed: &std::collections::HashSet<Aesthetic>,
2216 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2222) -> Result<(), RenderError> {
2223 if matches!(theme.legend_position, LegendPosition::None) {
2224 return Ok(());
2225 }
2226
2227 let legend_scales = legend_scales_for(scales, suppressed);
2228 if legend_scales.is_empty() && guide.custom.is_empty() {
2229 return Ok(());
2230 }
2231 backend.set_clipping(false);
2233 let drawn = draw_legend_planned_inner(
2234 scales,
2235 theme,
2236 plot_area,
2237 legend_avail_width,
2238 backend,
2239 guide,
2240 legend_plans,
2241 legend_scales,
2242 );
2243 backend.set_clipping(true);
2244 drawn
2245}
2246
2247#[allow(clippy::too_many_arguments)]
2248fn draw_legend_planned_inner(
2249 scales: &ScaleSet,
2250 theme: &Theme,
2251 plot_area: &Rect,
2252 legend_avail_width: f64,
2253 backend: &mut dyn DrawBackend,
2254 guide: &GuideLegend,
2255 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
2256 legend_scales: Vec<Aesthetic>,
2257) -> Result<(), RenderError> {
2258 let groups = group_mergeable_legends(scales, &legend_scales, guide);
2268 let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2269
2270 let is_horizontal_hint = match theme.legend_direction {
2282 Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
2283 None => matches!(
2284 theme.legend_position,
2285 LegendPosition::Top | LegendPosition::Bottom
2286 ),
2287 };
2288 let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
2289 let legend_size_hint =
2295 estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
2296 let canvas = backend.total_area();
2297 let (legend_x, legend_y, mut is_horizontal) = legend_position(
2298 theme,
2299 plot_area,
2300 legend_extra_rows,
2301 legend_size_hint,
2302 inside_title_lift(scales, &representatives, theme, guide),
2303 &canvas,
2304 );
2305 if let Some(dir) = theme.legend_direction {
2306 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
2307 }
2308 let n_wrap = wrap_guide_count(scales, &representatives);
2309 let colorbar_width = colorbar_guides_width(scales, &representatives, theme, guide);
2317 let per_guide_width = ((legend_avail_width - colorbar_width) / n_wrap as f64).max(0.0);
2318
2319 let mut offset_y = legend_y;
2320 let mut offset_x = legend_x;
2321
2322 let just = theme.legend_box_just.clamp(0.0, 1.0);
2327 let cross: Vec<f64> = if just > 0.0 {
2328 representatives
2329 .iter()
2330 .map(|aes| {
2331 if is_horizontal {
2332 horizontal_guide_height(scales, aes, theme, guide, legend_plans)
2333 } else {
2334 estimate_guide_size(scales, aes, theme, guide, legend_plans)
2335 .map_or(0.0, |(w, _)| w)
2336 }
2337 })
2338 .collect()
2339 } else {
2340 vec![0.0; representatives.len()]
2341 };
2342 let cross_max = cross.iter().copied().fold(0.0_f64, f64::max);
2343
2344 for (gi, group) in groups.iter().enumerate() {
2345 let shift = (cross_max - cross[gi]) * just;
2346 let (draw_x, draw_y) = if is_horizontal {
2347 (offset_x, offset_y + shift)
2348 } else {
2349 (offset_x + shift, offset_y)
2350 };
2351 let representative = &group[0];
2352 let rep_scale = scales.get(representative).unwrap();
2353 let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
2354 .iter()
2355 .map(|a| (a.clone(), scales.get(a).unwrap()))
2356 .collect();
2357
2358 if !rep_scale.is_discrete() && is_colorbar_aesthetic(representative) {
2359 let height = draw_continuous_legend_at(
2371 rep_scale,
2372 theme,
2373 draw_x,
2374 draw_y,
2375 backend,
2376 guide,
2377 is_horizontal,
2378 )?;
2379 if is_horizontal {
2380 let (bar_len, _) =
2384 continuous_bar_dims(theme, true, &guide.colourbar_for(representative));
2385 offset_x += bar_len + theme.legend_spacing * 2.0;
2386 } else {
2387 offset_y += height + theme.legend_spacing * 2.0;
2388 }
2389 continue;
2390 }
2391
2392 match legend_plans.get(representative) {
2397 None => {
2398 if is_horizontal {
2399 let width = draw_discrete_legend_at(
2400 scales,
2401 &scale_group,
2402 theme,
2403 draw_x,
2404 draw_y,
2405 backend,
2406 guide,
2407 true,
2408 )?;
2409 offset_x += width + theme.legend_spacing * 2.0;
2410 } else {
2411 let height = draw_discrete_legend_at(
2412 scales,
2413 &scale_group,
2414 theme,
2415 draw_x,
2416 draw_y,
2417 backend,
2418 guide,
2419 false,
2420 )?;
2421 offset_y += height + theme.legend_spacing * 2.0;
2422 }
2423 }
2424 Some(plan) => {
2425 let height = draw_discrete_legend_grid(
2426 scales,
2427 &scale_group,
2428 theme,
2429 draw_x,
2430 draw_y,
2431 backend,
2432 guide,
2433 plan,
2434 )?;
2435 if is_horizontal {
2436 offset_x += per_guide_width;
2440 } else {
2441 offset_y += height + theme.legend_spacing * 2.0;
2442 }
2443 }
2444 }
2445 }
2446
2447 let family = (!theme.legend_title.family.is_empty()).then(|| theme.legend_title.family.clone());
2449 for custom in &guide.custom {
2450 let title_offset = custom.title_offset(theme);
2451 if let Some(title) = custom.title.as_deref().filter(|t| !t.is_empty()) {
2452 backend.set_element_class(Some("gg-legend gg-legend-title"));
2453 crate::render::rich::draw(
2454 backend,
2455 theme.rich_text,
2456 title,
2457 (offset_x, offset_y),
2458 &TextStyle {
2459 color: theme.legend_title.color,
2460 size: theme.legend_title.size,
2461 anchor: TextAnchor::Start,
2462 angle: 0.0,
2463 family: family.clone(),
2464 face: theme.legend_title.face,
2465 bg_colour: None,
2466 bg_r: 0.0,
2467 },
2468 )?;
2469 }
2470 let rect = Rect {
2471 x: offset_x,
2472 y: offset_y + title_offset,
2473 width: custom.width,
2474 height: custom.height,
2475 };
2476 backend.set_element_class(Some("gg-legend gg-legend-custom"));
2477 (custom.draw)(backend, &rect)?;
2478 if is_horizontal {
2479 offset_x += custom.width + theme.legend_spacing * 2.0;
2480 } else {
2481 offset_y += title_offset + custom.height + theme.legend_spacing * 2.0;
2482 }
2483 }
2484 Ok(())
2485}
2486
2487#[allow(clippy::too_many_arguments)]
2491fn draw_discrete_legend_grid(
2494 scales: &ScaleSet,
2495 group: &[(Aesthetic, &dyn crate::scale::Scale)],
2496 theme: &Theme,
2497 legend_x: f64,
2498 legend_y: f64,
2499 backend: &mut dyn DrawBackend,
2500 guide: &GuideLegend,
2501 plan: &LegendPlan,
2502) -> Result<f64, RenderError> {
2503 let (_, scale) = &group[0];
2504 let mut breaks = scale.breaks();
2505 if breaks.is_empty() {
2506 return Ok(0.0);
2507 }
2508 if guide.is_reversed(&group[0].0) {
2509 breaks.reverse();
2510 }
2511
2512 let diameter = group
2514 .iter()
2515 .map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
2516 .fold(0.0_f64, f64::max);
2517 let swatch_size = theme.legend_key_width.max(diameter);
2518 let item_height = theme.legend_key_height.max(swatch_size);
2519 let gap_y = theme.legend_key_gap_y();
2521 let row_pitch = item_height + gap_y;
2522
2523 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
2524 let legend_family = if theme.legend_title.family.is_empty() {
2525 None
2526 } else {
2527 Some(theme.legend_title.family.clone())
2528 };
2529 let title_offset = if !title.is_empty() {
2530 backend.set_element_class(Some("gg-legend gg-legend-title"));
2531 crate::render::rich::draw(
2532 backend,
2533 theme.rich_text,
2534 title,
2535 (legend_x, legend_y),
2536 &TextStyle {
2537 color: theme.legend_title.color,
2538 size: theme.legend_title.size,
2539 anchor: TextAnchor::Start,
2540 angle: 0.0,
2541 family: legend_family,
2542 face: theme.legend_title.face,
2543 bg_colour: None,
2544 bg_r: 0.0,
2545 },
2546 )?;
2547 theme.legend_title.size + 4.0
2548 } else {
2549 0.0
2550 };
2551 let items_y = legend_y + title_offset;
2552
2553 for entry in &plan.entries {
2554 let (pos, label) = &breaks[entry.break_idx];
2555 let x = legend_x + entry.x_offset;
2556 let y = items_y + entry.row as f64 * row_pitch;
2557 let center_x = x + swatch_size / 2.0;
2558 let center_y = y + swatch_size / 2.0;
2559
2560 if theme.legend_key.visible {
2561 if let Some(fill) = theme.legend_key.fill {
2562 backend.set_element_class(Some("gg-legend gg-legend-key"));
2563 backend.draw_rect(
2564 (x, y),
2565 (x + swatch_size, y + swatch_size),
2566 &RectStyle {
2567 fill: Some(fill),
2568 stroke: theme.legend_key.color,
2569 stroke_width: theme.legend_key.width,
2570 alpha: 1.0,
2571 clip: false,
2572 corner_radius: 0.0,
2573 },
2574 )?;
2575 }
2576 }
2577
2578 backend.set_element_class(Some("gg-legend gg-legend-key"));
2579 draw_group_keys(
2580 backend,
2581 scales,
2582 group,
2583 theme,
2584 guide,
2585 label,
2586 *pos,
2587 (x, y, center_x, center_y),
2588 swatch_size,
2589 )?;
2590
2591 let label_family = if theme.legend_text.family.is_empty() {
2592 None
2593 } else {
2594 Some(theme.legend_text.family.clone())
2595 };
2596 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
2600 backend.set_element_class(Some("gg-legend gg-legend-text"));
2601 backend.draw_text_with_title(
2602 display,
2603 (x + swatch_size + theme.legend_spacing, center_y),
2604 &TextStyle {
2605 color: theme.legend_text.color,
2606 size: theme.legend_text.size,
2607 anchor: TextAnchor::Start,
2608 angle: 0.0,
2609 family: label_family,
2610 face: theme.legend_text.face,
2611 bg_colour: None,
2612 bg_r: 0.0,
2613 },
2614 entry.label_override.as_deref().map(|_| label.as_str()),
2615 )?;
2616 }
2617
2618 if let Some(ov) = &plan.overflow {
2620 let x = legend_x + ov.x_offset;
2621 let y = items_y + ov.row as f64 * row_pitch + swatch_size / 2.0;
2622 let label_family = if theme.legend_text.family.is_empty() {
2623 None
2624 } else {
2625 Some(theme.legend_text.family.clone())
2626 };
2627 backend.set_element_class(Some("gg-legend gg-legend-text"));
2628 crate::render::rich::draw(
2629 backend,
2630 theme.rich_text,
2631 &ov.text,
2632 (x, y),
2633 &TextStyle {
2634 color: theme.legend_text.color,
2635 size: theme.legend_text.size,
2636 anchor: TextAnchor::Start,
2637 angle: 0.0,
2638 family: label_family,
2639 face: theme.legend_text.face,
2640 bg_colour: None,
2641 bg_r: 0.0,
2642 },
2643 )?;
2644 }
2645
2646 Ok(title_offset + plan.rows as f64 * row_pitch - gap_y)
2647}
2648
2649#[cfg(test)]
2650mod group_mergeable_legends_tests {
2651 use super::*;
2652 use crate::scale::color::RGBAColor;
2653 use crate::scale::manual::ScaleManual;
2654
2655 #[test]
2656 fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
2657 let mut scales = ScaleSet::new();
2664 scales.add(Box::new(ScaleManual::new(
2665 Aesthetic::Color,
2666 vec![("a", RGBAColor::new(200, 0, 0))],
2667 )));
2668 let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
2669 let guide = GuideLegend::default();
2670 let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2671 assert_eq!(groups, vec![vec![Aesthetic::Color]]);
2672 }
2673}
2674
2675#[cfg(test)]
2681mod continuous_bar_dims_tests {
2682 use super::*;
2683
2684 #[test]
2685 fn dims_swap_between_vertical_and_horizontal() {
2686 let theme = Theme::default();
2687 let (w, h) = continuous_bar_dims(&theme, false, &GuideColourbar::default());
2688 assert_eq!(w, theme.legend_key_width);
2689 assert_eq!(h, theme.legend_key_height * 8.0);
2690
2691 let (w, h) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
2692 assert_eq!(w, theme.legend_key_height * 8.0);
2693 assert_eq!(h, theme.legend_key_width);
2694 }
2695
2696 #[test]
2697 fn horizontal_bar_height_adds_a_title_line_only_when_there_is_a_title() {
2698 let theme = Theme::default();
2699 let without_title =
2700 continuous_horizontal_bar_height(&theme, false, &GuideColourbar::default());
2701 let with_title = continuous_horizontal_bar_height(&theme, true, &GuideColourbar::default());
2702 assert!(with_title > without_title);
2703 assert!(((with_title - without_title) - (theme.legend_title.size + 4.0)).abs() < 1e-9);
2704
2705 let (_, thickness) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
2710 let tick_len = 3.0;
2711 let label_line = theme.legend_text.size + 4.0;
2712 assert!(
2713 (without_title - (thickness + tick_len + theme.legend_spacing + label_line)).abs()
2714 < 1e-9
2715 );
2716 }
2717}
2718
2719#[cfg(test)]
2722mod inside_legend_box_tests {
2723 use super::*;
2724 use crate::scale::color::RGBAColor;
2725 use crate::scale::manual::ScaleManual;
2726
2727 fn scales_with_a_legend() -> ScaleSet {
2728 let mut scales = ScaleSet::new();
2729 scales.add(Box::new(ScaleManual::new(
2730 Aesthetic::Color,
2731 vec![
2732 ("a", RGBAColor::new(200, 0, 0)),
2733 ("b", RGBAColor::new(0, 0, 200)),
2734 ],
2735 )));
2736 scales
2737 }
2738
2739 fn areas() -> (Rect, Rect) {
2740 let plot_area = Rect {
2741 x: 10.0,
2742 y: 10.0,
2743 width: 200.0,
2744 height: 150.0,
2745 };
2746 let canvas = Rect {
2747 x: 0.0,
2748 y: 0.0,
2749 width: 220.0,
2750 height: 170.0,
2751 };
2752 (plot_area, canvas)
2753 }
2754
2755 #[test]
2756 fn non_inside_positions_claim_nothing() {
2757 let scales = scales_with_a_legend();
2758 let (plot_area, canvas) = areas();
2759 let guide = GuideLegend::default();
2760 let suppressed = std::collections::HashSet::new();
2761 let legend_plans = std::collections::HashMap::new();
2762 for pos in [
2763 LegendPosition::Right,
2764 LegendPosition::Left,
2765 LegendPosition::Top,
2766 LegendPosition::Bottom,
2767 LegendPosition::None,
2768 ] {
2769 let theme = Theme {
2770 legend_position: pos,
2771 ..Theme::default()
2772 };
2773 assert!(inside_legend_box(
2774 &scales,
2775 &theme,
2776 &plot_area,
2777 &canvas,
2778 &guide,
2779 &suppressed,
2780 &legend_plans
2781 )
2782 .is_none());
2783 }
2784 }
2785
2786 #[test]
2787 fn no_legend_scales_claims_nothing_even_when_inside() {
2788 let scales = ScaleSet::new(); let (plot_area, canvas) = areas();
2790 let theme = Theme {
2791 legend_position: LegendPosition::Inside(0.85, 0.85),
2792 ..Theme::default()
2793 };
2794 let guide = GuideLegend::default();
2795 let suppressed = std::collections::HashSet::new();
2796 let legend_plans = std::collections::HashMap::new();
2797 assert!(inside_legend_box(
2798 &scales,
2799 &theme,
2800 &plot_area,
2801 &canvas,
2802 &guide,
2803 &suppressed,
2804 &legend_plans
2805 )
2806 .is_none());
2807 }
2808
2809 #[test]
2810 fn inside_position_with_a_legend_claims_a_box_within_the_panel() {
2811 let scales = scales_with_a_legend();
2812 let (plot_area, canvas) = areas();
2813 let theme = Theme {
2814 legend_position: LegendPosition::Inside(0.85, 0.85),
2815 ..Theme::default()
2816 };
2817 let guide = GuideLegend::default();
2818 let suppressed = std::collections::HashSet::new();
2819 let legend_plans = std::collections::HashMap::new();
2820 let rect = inside_legend_box(
2821 &scales,
2822 &theme,
2823 &plot_area,
2824 &canvas,
2825 &guide,
2826 &suppressed,
2827 &legend_plans,
2828 )
2829 .expect("an Inside legend with a mapped scale claims a box");
2830 assert!(rect.width > 0.0 && rect.height > 0.0);
2831 assert!(rect.x >= canvas.x - 1e-6 && rect.x + rect.width <= canvas.x + canvas.width + 1e-6);
2832 assert!(
2833 rect.y >= canvas.y - 1e-6 && rect.y + rect.height <= canvas.y + canvas.height + 1e-6
2834 );
2835 }
2836
2837 #[test]
2838 fn matches_the_position_draw_legend_planned_itself_uses() {
2839 let scales = scales_with_a_legend();
2844 let (plot_area, canvas) = areas();
2845 let theme = Theme {
2846 legend_position: LegendPosition::Inside(0.85, 0.85),
2847 ..Theme::default()
2848 };
2849 let guide = GuideLegend::default();
2850 let suppressed = std::collections::HashSet::new();
2851 let legend_plans = std::collections::HashMap::new();
2852
2853 let rect = inside_legend_box(
2854 &scales,
2855 &theme,
2856 &plot_area,
2857 &canvas,
2858 &guide,
2859 &suppressed,
2860 &legend_plans,
2861 )
2862 .unwrap();
2863
2864 let legend_scales = legend_scales_for(&scales, &suppressed);
2865 let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
2866 let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
2867 let size_hint =
2868 estimate_legend_box(&scales, &representatives, &theme, &guide, &legend_plans);
2869 let lift = inside_title_lift(&scales, &representatives, &theme, &guide);
2870 let (x, y, _) = legend_position(&theme, &plot_area, 0, size_hint, lift, &canvas);
2871
2872 assert_eq!(
2874 (rect.x, rect.y, rect.width, rect.height),
2875 (x, y - lift, size_hint.0, size_hint.1 + lift)
2876 );
2877 }
2878}