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 legend_scales_for(
28 scales: &ScaleSet,
29 suppressed: &std::collections::HashSet<Aesthetic>,
30) -> Vec<Aesthetic> {
31 let mut legend_scales: Vec<Aesthetic> = Vec::new();
32 for aes in LEGEND_AESTHETICS {
33 if suppressed.contains(aes) {
34 continue;
35 }
36 if let Some(scale) = scales.get(aes) {
37 if !scale.breaks().is_empty() {
38 if *aes == Aesthetic::Fill && legend_scales.contains(&Aesthetic::Color) {
39 continue;
40 }
41 legend_scales.push(aes.clone());
42 }
43 }
44 }
45 legend_scales
46}
47
48#[deprecated(
60 since = "0.23.0",
61 note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
62 sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
63 by GGPlot's render methods. Will be removed in a later minor release."
64)]
65pub fn draw_legend(
66 scales: &ScaleSet,
67 theme: &Theme,
68 plot_area: &Rect,
69 backend: &mut dyn DrawBackend,
70 guide: &GuideLegend,
71 suppressed: &std::collections::HashSet<Aesthetic>,
72) -> Result<(), RenderError> {
73 if matches!(theme.legend_position, LegendPosition::None) {
74 return Ok(());
75 }
76
77 let mut legend_scales: Vec<&Aesthetic> = Vec::new();
79 for aes in LEGEND_AESTHETICS {
80 if suppressed.contains(aes) {
82 continue;
83 }
84 if let Some(scale) = scales.get(aes) {
85 if !scale.breaks().is_empty() {
86 if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
88 continue;
89 }
90 legend_scales.push(aes);
91 }
92 }
93 }
94
95 if legend_scales.is_empty() {
96 return Ok(());
97 }
98
99 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
101 if let Some(dir) = theme.legend_direction {
103 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
104 }
105
106 let mut offset_y = legend_y;
107 let mut offset_x = legend_x;
108
109 for aes in &legend_scales {
110 let scale = scales.get(aes).unwrap();
111
112 if scale.is_discrete() {
113 if is_horizontal {
114 let width = draw_discrete_legend_at(
115 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
116 )?;
117 offset_x += width + theme.legend_spacing * 2.0;
118 } else {
119 let height = draw_discrete_legend_at(
120 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
121 )?;
122 offset_y += height + theme.legend_spacing * 2.0;
123 }
124 } else {
125 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
127 let height =
128 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
129 if is_horizontal {
130 offset_x += theme.legend_key_width
131 + theme.legend_text.size * 6.0
132 + theme.legend_spacing * 2.0;
133 } else {
134 offset_y += height + theme.legend_spacing * 2.0;
135 }
136 } else {
137 let height = draw_discrete_legend_at(
139 scales,
140 aes,
141 scale,
142 theme,
143 offset_x,
144 offset_y,
145 backend,
146 guide,
147 is_horizontal,
148 )?;
149 if is_horizontal {
150 offset_x += theme.legend_key_width
151 + theme.legend_text.size * 6.0
152 + theme.legend_spacing * 2.0;
153 } else {
154 offset_y += height + theme.legend_spacing * 2.0;
155 }
156 }
157 }
158 }
159
160 Ok(())
161}
162
163pub(crate) fn legend_position(theme: &Theme, plot_area: &Rect) -> (f64, f64, bool) {
166 match theme.legend_position {
167 LegendPosition::Right => (
168 plot_area.x + plot_area.width + theme.legend_margin.left,
169 plot_area.y + theme.legend_margin.top,
170 false,
171 ),
172 LegendPosition::Left => (
173 theme.legend_margin.left,
174 plot_area.y + theme.legend_margin.top,
175 false,
176 ),
177 LegendPosition::Top => (
178 plot_area.x + theme.legend_margin.left,
179 theme.legend_margin.top,
180 true,
181 ),
182 LegendPosition::Bottom => (
183 plot_area.x + theme.legend_margin.left,
184 plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
185 true,
186 ),
187 LegendPosition::None => (0.0, 0.0, false),
188 LegendPosition::Inside(fx, fy) => (
189 plot_area.x + fx * plot_area.width,
190 plot_area.y + (1.0 - fy) * plot_area.height,
191 false,
192 ),
193 }
194}
195
196#[allow(clippy::too_many_arguments)]
198fn draw_discrete_legend_at(
199 scales: &ScaleSet,
200 aes: &Aesthetic,
201 scale: &dyn crate::scale::Scale,
202 theme: &Theme,
203 legend_x: f64,
204 legend_y: f64,
205 backend: &mut dyn DrawBackend,
206 guide: &GuideLegend,
207 is_horizontal: bool,
208) -> Result<f64, RenderError> {
209 let mut breaks = scale.breaks();
210 if breaks.is_empty() {
211 return Ok(0.0);
212 }
213
214 if guide.reverse {
216 breaks.reverse();
217 }
218
219 let item_height = theme.legend_key_height;
220 let swatch_size = theme.legend_key_width;
221
222 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
224 let legend_family = if theme.legend_title.family.is_empty() {
225 None
226 } else {
227 Some(theme.legend_title.family.clone())
228 };
229 let title_offset = if !title.is_empty() {
230 backend.draw_text(
231 title,
232 (legend_x, legend_y),
233 &TextStyle {
234 color: theme.legend_title.color,
235 size: theme.legend_title.size,
236 anchor: TextAnchor::Start,
237 angle: 0.0,
238 family: legend_family,
239 face: theme.legend_title.face,
240 },
241 )?;
242 theme.legend_title.size + 4.0
243 } else {
244 0.0
245 };
246
247 let items_y = legend_y + title_offset;
248
249 if theme.legend_background.visible {
251 let total_height = breaks.len() as f64 * item_height;
252 let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
253 if let Some(fill) = theme.legend_background.fill {
254 backend.draw_rect(
255 (legend_x - 2.0, items_y - 2.0),
256 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
257 &RectStyle {
258 fill: Some(fill),
259 stroke: theme.legend_background.color,
260 stroke_width: theme.legend_background.width,
261 alpha: 1.0,
262 clip: false,
263 },
264 )?;
265 }
266 }
267
268 let mut cur_x = legend_x;
270 for (i, (pos, label)) in breaks.iter().enumerate() {
271 let x = if is_horizontal { cur_x } else { legend_x };
272 let y = if is_horizontal {
273 items_y
274 } else {
275 items_y + i as f64 * item_height
276 };
277 let center_x = x + swatch_size / 2.0;
278 let center_y = y + swatch_size / 2.0;
279
280 if theme.legend_key.visible {
282 if let Some(fill) = theme.legend_key.fill {
283 backend.draw_rect(
284 (x, y),
285 (x + swatch_size, y + swatch_size),
286 &RectStyle {
287 fill: Some(fill),
288 stroke: theme.legend_key.color,
289 stroke_width: theme.legend_key.width,
290 alpha: 1.0,
291 clip: false,
292 },
293 )?;
294 }
295 }
296
297 draw_legend_swatch(
299 backend,
300 scales,
301 aes,
302 scale,
303 theme,
304 label,
305 *pos,
306 x,
307 y,
308 center_x,
309 center_y,
310 swatch_size,
311 )?;
312
313 let label_family = if theme.legend_text.family.is_empty() {
315 None
316 } else {
317 Some(theme.legend_text.family.clone())
318 };
319 backend.draw_text(
320 label,
321 (x + swatch_size + theme.legend_spacing, center_y),
322 &TextStyle {
323 color: theme.legend_text.color,
324 size: theme.legend_text.size,
325 anchor: TextAnchor::Start,
326 angle: 0.0,
327 family: label_family,
328 face: theme.legend_text.face,
329 },
330 )?;
331 if is_horizontal {
332 cur_x = x
334 + swatch_size
335 + theme.legend_spacing
336 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
337 + theme.legend_spacing * 3.0;
338 }
339 }
340
341 if is_horizontal {
342 Ok(cur_x - legend_x) } else {
344 Ok(title_offset + breaks.len() as f64 * item_height)
345 }
346}
347
348fn draw_continuous_legend_at(
350 scale: &dyn crate::scale::Scale,
351 theme: &Theme,
352 legend_x: f64,
353 legend_y: f64,
354 backend: &mut dyn DrawBackend,
355 guide: &GuideLegend,
356) -> Result<f64, RenderError> {
357 let breaks = scale.breaks();
358 if breaks.is_empty() {
359 return Ok(0.0);
360 }
361
362 let bar_width = theme.legend_key_width;
363 let bar_height = theme.legend_key_height * 8.0;
364
365 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
367 let cont_family = if theme.legend_title.family.is_empty() {
368 None
369 } else {
370 Some(theme.legend_title.family.clone())
371 };
372 let title_offset = if !title.is_empty() {
373 backend.draw_text(
374 title,
375 (legend_x, legend_y),
376 &TextStyle {
377 color: theme.legend_title.color,
378 size: theme.legend_title.size,
379 anchor: TextAnchor::Start,
380 angle: 0.0,
381 family: cont_family,
382 face: theme.legend_title.face,
383 },
384 )?;
385 theme.legend_title.size + 4.0
386 } else {
387 0.0
388 };
389
390 let bar_top = legend_y + title_offset;
391
392 if theme.legend_background.visible {
394 let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
395 if let Some(fill) = theme.legend_background.fill {
396 backend.draw_rect(
397 (legend_x - 2.0, bar_top - 2.0),
398 (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
399 &RectStyle {
400 fill: Some(fill),
401 stroke: theme.legend_background.color,
402 stroke_width: theme.legend_background.width,
403 alpha: 1.0,
404 clip: false,
405 },
406 )?;
407 }
408 }
409
410 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
413 let n_slices = 50;
414 let slice_height = bar_height / n_slices as f64;
415 for i in 0..n_slices {
416 let t = 1.0 - i as f64 / n_slices as f64;
417 let data_val = data_min + t * (data_max - data_min);
418 let color = scale
419 .map_to_color(&Value::Float(data_val))
420 .unwrap_or((127, 127, 127));
421 let sy = bar_top + i as f64 * slice_height;
422 backend.draw_rect(
423 (legend_x, sy),
424 (legend_x + bar_width, sy + slice_height + 0.5),
425 &RectStyle {
426 fill: Some(color),
427 stroke: None,
428 stroke_width: 0.0,
429 alpha: 1.0,
430 clip: false,
431 },
432 )?;
433 }
434
435 let border = theme.legend_bar_border_line();
438 let border_style = LineStyle {
439 color: border.color,
440 width: border.width,
441 alpha: 1.0,
442 linetype: border.linetype,
443 ..Default::default()
444 };
445 if border.visible {
446 backend.draw_line(
447 &[
448 (legend_x, bar_top),
449 (legend_x + bar_width, bar_top),
450 (legend_x + bar_width, bar_top + bar_height),
451 (legend_x, bar_top + bar_height),
452 (legend_x, bar_top),
453 ],
454 &border_style,
455 )?;
456 }
457
458 let tick_len = 3.0;
460 for (pos, label) in &breaks {
461 let tick_y = bar_top + bar_height * (1.0 - pos);
462 backend.draw_line(
463 &[
464 (legend_x + bar_width, tick_y),
465 (legend_x + bar_width + tick_len, tick_y),
466 ],
467 &border_style,
468 )?;
469 let tick_family = if theme.legend_text.family.is_empty() {
470 None
471 } else {
472 Some(theme.legend_text.family.clone())
473 };
474 backend.draw_text(
475 label,
476 (
477 legend_x + bar_width + tick_len + theme.legend_spacing,
478 tick_y,
479 ),
480 &TextStyle {
481 color: theme.legend_text.color,
482 size: theme.legend_text.size,
483 anchor: TextAnchor::Start,
484 angle: 0.0,
485 family: tick_family,
486 face: theme.legend_text.face,
487 },
488 )?;
489 }
490
491 Ok(title_offset + bar_height)
492}
493
494#[allow(clippy::too_many_arguments)]
500fn draw_legend_swatch(
501 backend: &mut dyn DrawBackend,
502 scales: &ScaleSet,
503 aes: &Aesthetic,
504 scale: &dyn crate::scale::Scale,
505 theme: &Theme,
506 label: &str,
507 pos: f64,
508 x: f64,
509 y: f64,
510 center_x: f64,
511 center_y: f64,
512 swatch_size: f64,
513) -> Result<(), RenderError> {
514 let value = Value::Str(label.to_string());
515 match aes {
516 Aesthetic::Color | Aesthetic::Fill => {
517 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
518 backend.draw_rect(
519 (x, y),
520 (x + swatch_size, y + swatch_size),
521 &RectStyle {
522 fill: Some(color),
523 stroke: None,
524 stroke_width: 0.0,
525 alpha: 1.0,
526 clip: false,
527 },
528 )?;
529 }
530 Aesthetic::Shape => {
531 let shape = scales
532 .map_shape(&value)
533 .unwrap_or(crate::render::backend::PointShape::Circle);
534 backend.draw_shape(
535 (center_x, center_y),
536 swatch_size / 3.0,
537 &PointStyle {
538 color: theme.geom.ink.unwrap_or((50, 50, 50)),
539 alpha: 1.0,
540 filled: true,
541 shape,
542 },
543 )?;
544 }
545 Aesthetic::Linetype => {
546 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
547 backend.draw_line(
548 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
549 &LineStyle {
550 color: theme.geom.ink.unwrap_or((50, 50, 50)),
551 width: 1.5,
552 alpha: 1.0,
553 linetype: lt,
554 ..Default::default()
555 },
556 )?;
557 }
558 Aesthetic::Size => {
559 let size = scale
560 .map_fraction_to_size(pos)
561 .or_else(|| scales.map_size(&value))
562 .unwrap_or(3.0);
563 backend.draw_shape(
564 (center_x, center_y),
565 size.min(swatch_size / 2.0),
566 &PointStyle {
567 color: theme.geom.ink.unwrap_or((50, 50, 50)),
568 alpha: 1.0,
569 filled: true,
570 shape: crate::render::backend::PointShape::Circle,
571 },
572 )?;
573 }
574 Aesthetic::Alpha => {
575 let alpha = scale
576 .map_fraction_to_alpha(pos)
577 .or_else(|| scales.map_alpha(&value))
578 .unwrap_or(1.0);
579 backend.draw_rect(
580 (x, y),
581 (x + swatch_size, y + swatch_size),
582 &RectStyle {
583 fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
584 stroke: None,
585 stroke_width: 0.0,
586 alpha,
587 clip: false,
588 },
589 )?;
590 }
591 Aesthetic::Linewidth => {
592 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
593 backend.draw_line(
594 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
595 &LineStyle {
596 color: theme.geom.ink.unwrap_or((50, 50, 50)),
597 width: width.min(swatch_size),
598 alpha: 1.0,
599 linetype: Linetype::Solid,
600 ..Default::default()
601 },
602 )?;
603 }
604 _ => {}
605 }
606 Ok(())
607}
608
609#[derive(Clone, Debug)]
615pub(crate) struct LegendEntry {
616 pub break_idx: usize,
617 pub row: usize,
618 pub x_offset: f64,
619 pub label_override: Option<String>,
626}
627
628#[derive(Clone, Debug)]
632pub(crate) struct LegendOverflow {
633 pub dropped: usize,
634 pub row: usize,
635 pub x_offset: f64,
636 pub text: String,
637}
638
639#[derive(Clone, Debug)]
644pub(crate) struct LegendPlan {
645 pub rows: usize,
646 pub entries: Vec<LegendEntry>,
647 pub overflow: Option<LegendOverflow>,
648 pub cols: usize,
653 pub col_pitch: f64,
656}
657
658fn legend_pitch(label: &str, theme: &Theme) -> f64 {
663 let swatch = theme.legend_key_width;
664 let spacing = theme.legend_spacing;
665 let text_size = theme.legend_text.size;
666 let legacy =
667 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
668 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
669 legacy.max(measured)
670}
671
672pub(crate) fn plan_legend(
680 breaks: &[(f64, String)],
681 guide: &GuideLegend,
682 theme: &Theme,
683 avail_width: f64,
684 avail_height: f64,
685 is_horizontal: bool,
686) -> Option<LegendPlan> {
687 let n = breaks.len();
688 if n == 0 {
689 return None;
690 }
691
692 if guide.nrow.is_some() || guide.ncol.is_some() {
694 let (rows, cols) = match (guide.nrow, guide.ncol) {
695 (Some(nrow), Some(ncol)) => (nrow, ncol),
696 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
697 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
698 (None, None) => unreachable!("checked above"),
699 };
700 let rows = rows.max(1);
701 let cols = cols.max(1);
702 let col_pitch = breaks
703 .iter()
704 .map(|(_, l)| legend_pitch(l, theme))
705 .fold(0.0_f64, f64::max);
706 let entries = (0..n)
707 .map(|i| {
708 let (row, col) = if guide.byrow {
709 (i / cols, i % cols)
710 } else {
711 (i % rows, i / rows)
712 };
713 LegendEntry {
714 break_idx: i,
715 row,
716 x_offset: col as f64 * col_pitch,
717 label_override: None,
718 }
719 })
720 .collect();
721 return Some(LegendPlan {
722 rows,
723 entries,
724 overflow: None,
725 cols,
726 col_pitch,
727 });
728 }
729
730 if !is_horizontal {
732 return None;
733 }
734
735 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
736 let total_width: f64 = pitches.iter().sum();
737 if total_width <= avail_width.max(0.0) {
738 return None; }
740
741 let item_height = theme.legend_key_height.max(1.0);
742 let row_limit = guide
743 .max_rows
744 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
745 .max(1);
746
747 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
748 let mut row = 0usize;
749 let mut cur_x = 0.0_f64;
750 for (i, &p) in pitches.iter().enumerate() {
751 if cur_x > 0.0 && cur_x + p > avail_width {
752 if row + 1 >= row_limit {
753 break; }
755 row += 1;
756 cur_x = 0.0;
757 }
758 entries.push(LegendEntry {
759 break_idx: i,
760 row,
761 x_offset: cur_x,
762 label_override: None,
763 });
764 cur_x += p;
765 }
766
767 if entries.len() >= n {
768 return Some(LegendPlan {
769 rows: row + 1,
770 entries,
771 overflow: None,
772 cols: 1,
773 col_pitch: 0.0,
774 });
775 }
776
777 let mut dropped = n - entries.len();
780 loop {
781 let text = format!("+{dropped} more");
782 let more_pitch = legend_pitch(&text, theme);
783 let row_count = entries.iter().filter(|e| e.row == row).count();
784 let last_x = entries
785 .iter()
786 .filter(|e| e.row == row)
787 .map(|e| e.x_offset + pitches[e.break_idx])
788 .fold(0.0_f64, f64::max);
789 if last_x + more_pitch <= avail_width || row_count == 0 {
790 return Some(LegendPlan {
791 rows: row + 1,
792 entries,
793 overflow: Some(LegendOverflow {
794 dropped,
795 row,
796 x_offset: last_x,
797 text,
798 }),
799 cols: 1,
800 col_pitch: 0.0,
801 });
802 }
803 entries.pop();
804 dropped += 1;
805 }
806}
807
808fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
812 legend_scales
813 .iter()
814 .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
815 .count()
816 .max(1)
817}
818
819#[allow(clippy::too_many_arguments)]
833pub(crate) fn plan_all_legends(
834 scales: &ScaleSet,
835 guide: &GuideLegend,
836 theme: &Theme,
837 suppressed: &std::collections::HashSet<Aesthetic>,
838 avail_width: f64,
839 avail_height: f64,
840 is_horizontal: bool,
841 warnings: &mut Vec<String>,
842) -> std::collections::HashMap<Aesthetic, LegendPlan> {
843 let legend_scales = legend_scales_for(scales, suppressed);
844 let n_discrete = discrete_guide_count(scales, &legend_scales);
845 let per_guide_width = if is_horizontal {
846 (avail_width / n_discrete as f64).max(0.0)
847 } else {
848 avail_width
849 };
850
851 let mut plans = std::collections::HashMap::new();
852 for aes in &legend_scales {
853 let Some(scale) = scales.get(aes) else {
854 continue;
855 };
856 if !scale.is_discrete() {
857 continue;
858 }
859 let breaks = scale.breaks();
860 let validated_guide = guide.validated(breaks.len(), warnings);
861 if let Some(plan) = plan_legend(
862 &breaks,
863 &validated_guide,
864 theme,
865 per_guide_width,
866 avail_height,
867 is_horizontal,
868 ) {
869 plans.insert(aes.clone(), plan);
870 }
871 }
872 plans
873}
874
875#[allow(clippy::too_many_arguments)]
885pub(crate) fn draw_legend_planned(
886 scales: &ScaleSet,
887 theme: &Theme,
888 plot_area: &Rect,
889 legend_avail_width: f64,
899 backend: &mut dyn DrawBackend,
900 guide: &GuideLegend,
901 suppressed: &std::collections::HashSet<Aesthetic>,
902 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
908) -> Result<(), RenderError> {
909 if matches!(theme.legend_position, LegendPosition::None) {
910 return Ok(());
911 }
912
913 let legend_scales = legend_scales_for(scales, suppressed);
914 if legend_scales.is_empty() {
915 return Ok(());
916 }
917
918 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
919 if let Some(dir) = theme.legend_direction {
920 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
921 }
922 let n_discrete = discrete_guide_count(scales, &legend_scales);
923 let per_guide_width = legend_avail_width / n_discrete as f64;
924
925 let mut offset_y = legend_y;
926 let mut offset_x = legend_x;
927
928 for aes in &legend_scales {
929 let scale = scales.get(aes).unwrap();
930 if !scale.is_discrete() {
931 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
934 let height =
935 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
936 if is_horizontal {
937 offset_x += theme.legend_key_width
938 + theme.legend_text.size * 6.0
939 + theme.legend_spacing * 2.0;
940 } else {
941 offset_y += height + theme.legend_spacing * 2.0;
942 }
943 } else {
944 let height = draw_discrete_legend_at(
945 scales,
946 aes,
947 scale,
948 theme,
949 offset_x,
950 offset_y,
951 backend,
952 guide,
953 is_horizontal,
954 )?;
955 if is_horizontal {
956 offset_x += theme.legend_key_width
957 + theme.legend_text.size * 6.0
958 + theme.legend_spacing * 2.0;
959 } else {
960 offset_y += height + theme.legend_spacing * 2.0;
961 }
962 }
963 continue;
964 }
965
966 match legend_plans.get(aes) {
967 None => {
968 if is_horizontal {
969 let width = draw_discrete_legend_at(
970 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
971 )?;
972 offset_x += width + theme.legend_spacing * 2.0;
973 } else {
974 let height = draw_discrete_legend_at(
975 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
976 )?;
977 offset_y += height + theme.legend_spacing * 2.0;
978 }
979 }
980 Some(plan) => {
981 let height = draw_discrete_legend_grid(
982 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
983 )?;
984 if is_horizontal {
985 offset_x += per_guide_width;
989 } else {
990 offset_y += height + theme.legend_spacing * 2.0;
991 }
992 }
993 }
994 }
995
996 Ok(())
997}
998
999#[allow(clippy::too_many_arguments)]
1003fn draw_discrete_legend_grid(
1004 scales: &ScaleSet,
1005 aes: &Aesthetic,
1006 scale: &dyn crate::scale::Scale,
1007 theme: &Theme,
1008 legend_x: f64,
1009 legend_y: f64,
1010 backend: &mut dyn DrawBackend,
1011 guide: &GuideLegend,
1012 plan: &LegendPlan,
1013) -> Result<f64, RenderError> {
1014 let mut breaks = scale.breaks();
1015 if breaks.is_empty() {
1016 return Ok(0.0);
1017 }
1018 if guide.reverse {
1019 breaks.reverse();
1020 }
1021
1022 let swatch_size = theme.legend_key_width;
1023 let item_height = theme.legend_key_height;
1024
1025 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1026 let legend_family = if theme.legend_title.family.is_empty() {
1027 None
1028 } else {
1029 Some(theme.legend_title.family.clone())
1030 };
1031 let title_offset = if !title.is_empty() {
1032 backend.draw_text(
1033 title,
1034 (legend_x, legend_y),
1035 &TextStyle {
1036 color: theme.legend_title.color,
1037 size: theme.legend_title.size,
1038 anchor: TextAnchor::Start,
1039 angle: 0.0,
1040 family: legend_family,
1041 face: theme.legend_title.face,
1042 },
1043 )?;
1044 theme.legend_title.size + 4.0
1045 } else {
1046 0.0
1047 };
1048 let items_y = legend_y + title_offset;
1049
1050 for entry in &plan.entries {
1051 let (pos, label) = &breaks[entry.break_idx];
1052 let x = legend_x + entry.x_offset;
1053 let y = items_y + entry.row as f64 * item_height;
1054 let center_x = x + swatch_size / 2.0;
1055 let center_y = y + swatch_size / 2.0;
1056
1057 if theme.legend_key.visible {
1058 if let Some(fill) = theme.legend_key.fill {
1059 backend.draw_rect(
1060 (x, y),
1061 (x + swatch_size, y + swatch_size),
1062 &RectStyle {
1063 fill: Some(fill),
1064 stroke: theme.legend_key.color,
1065 stroke_width: theme.legend_key.width,
1066 alpha: 1.0,
1067 clip: false,
1068 },
1069 )?;
1070 }
1071 }
1072
1073 draw_legend_swatch(
1074 backend,
1075 scales,
1076 aes,
1077 scale,
1078 theme,
1079 label,
1080 *pos,
1081 x,
1082 y,
1083 center_x,
1084 center_y,
1085 swatch_size,
1086 )?;
1087
1088 let label_family = if theme.legend_text.family.is_empty() {
1089 None
1090 } else {
1091 Some(theme.legend_text.family.clone())
1092 };
1093 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1097 backend.draw_text_with_title(
1098 display,
1099 (x + swatch_size + theme.legend_spacing, center_y),
1100 &TextStyle {
1101 color: theme.legend_text.color,
1102 size: theme.legend_text.size,
1103 anchor: TextAnchor::Start,
1104 angle: 0.0,
1105 family: label_family,
1106 face: theme.legend_text.face,
1107 },
1108 entry.label_override.as_deref().map(|_| label.as_str()),
1109 )?;
1110 }
1111
1112 if let Some(ov) = &plan.overflow {
1114 let x = legend_x + ov.x_offset;
1115 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1116 let label_family = if theme.legend_text.family.is_empty() {
1117 None
1118 } else {
1119 Some(theme.legend_text.family.clone())
1120 };
1121 backend.draw_text(
1122 &ov.text,
1123 (x, y),
1124 &TextStyle {
1125 color: theme.legend_text.color,
1126 size: theme.legend_text.size,
1127 anchor: TextAnchor::Start,
1128 angle: 0.0,
1129 family: label_family,
1130 face: theme.legend_text.face,
1131 },
1132 )?;
1133 }
1134
1135 Ok(title_offset + plan.rows as f64 * item_height)
1136}