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
819fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
826 legend_scales
827 .iter()
828 .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
829 .count()
830}
831
832fn continuous_guide_width(theme: &Theme) -> f64 {
837 theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
838}
839
840#[allow(clippy::too_many_arguments)]
854pub(crate) fn plan_all_legends(
855 scales: &ScaleSet,
856 guide: &GuideLegend,
857 theme: &Theme,
858 suppressed: &std::collections::HashSet<Aesthetic>,
859 avail_width: f64,
860 avail_height: f64,
861 is_horizontal: bool,
862 warnings: &mut Vec<String>,
863) -> std::collections::HashMap<Aesthetic, LegendPlan> {
864 let legend_scales = legend_scales_for(scales, suppressed);
865 let n_discrete = discrete_guide_count(scales, &legend_scales);
866 let per_guide_width = if is_horizontal {
867 let n_continuous = continuous_guide_count(scales, &legend_scales);
873 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
874 ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
875 } else {
876 avail_width
877 };
878
879 let mut plans = std::collections::HashMap::new();
880 for aes in &legend_scales {
881 let Some(scale) = scales.get(aes) else {
882 continue;
883 };
884 if !scale.is_discrete() {
885 continue;
886 }
887 let breaks = scale.breaks();
888 let validated_guide = guide.validated(breaks.len(), warnings);
889 if let Some(plan) = plan_legend(
890 &breaks,
891 &validated_guide,
892 theme,
893 per_guide_width,
894 avail_height,
895 is_horizontal,
896 ) {
897 plans.insert(aes.clone(), plan);
898 }
899 }
900 plans
901}
902
903#[allow(clippy::too_many_arguments)]
913pub(crate) fn draw_legend_planned(
914 scales: &ScaleSet,
915 theme: &Theme,
916 plot_area: &Rect,
917 legend_avail_width: f64,
927 backend: &mut dyn DrawBackend,
928 guide: &GuideLegend,
929 suppressed: &std::collections::HashSet<Aesthetic>,
930 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
936) -> Result<(), RenderError> {
937 if matches!(theme.legend_position, LegendPosition::None) {
938 return Ok(());
939 }
940
941 let legend_scales = legend_scales_for(scales, suppressed);
942 if legend_scales.is_empty() {
943 return Ok(());
944 }
945
946 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
947 if let Some(dir) = theme.legend_direction {
948 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
949 }
950 let n_discrete = discrete_guide_count(scales, &legend_scales);
951 let n_continuous = continuous_guide_count(scales, &legend_scales);
956 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
957 let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
958
959 let mut offset_y = legend_y;
960 let mut offset_x = legend_x;
961
962 for aes in &legend_scales {
963 let scale = scales.get(aes).unwrap();
964 if !scale.is_discrete() {
965 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
968 let height =
969 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
970 if is_horizontal {
971 offset_x += theme.legend_key_width
972 + theme.legend_text.size * 6.0
973 + theme.legend_spacing * 2.0;
974 } else {
975 offset_y += height + theme.legend_spacing * 2.0;
976 }
977 } else {
978 let height = draw_discrete_legend_at(
979 scales,
980 aes,
981 scale,
982 theme,
983 offset_x,
984 offset_y,
985 backend,
986 guide,
987 is_horizontal,
988 )?;
989 if is_horizontal {
990 offset_x += theme.legend_key_width
991 + theme.legend_text.size * 6.0
992 + theme.legend_spacing * 2.0;
993 } else {
994 offset_y += height + theme.legend_spacing * 2.0;
995 }
996 }
997 continue;
998 }
999
1000 match legend_plans.get(aes) {
1001 None => {
1002 if is_horizontal {
1003 let width = draw_discrete_legend_at(
1004 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1005 )?;
1006 offset_x += width + theme.legend_spacing * 2.0;
1007 } else {
1008 let height = draw_discrete_legend_at(
1009 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1010 )?;
1011 offset_y += height + theme.legend_spacing * 2.0;
1012 }
1013 }
1014 Some(plan) => {
1015 let height = draw_discrete_legend_grid(
1016 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1017 )?;
1018 if is_horizontal {
1019 offset_x += per_guide_width;
1023 } else {
1024 offset_y += height + theme.legend_spacing * 2.0;
1025 }
1026 }
1027 }
1028 }
1029
1030 Ok(())
1031}
1032
1033#[allow(clippy::too_many_arguments)]
1037fn draw_discrete_legend_grid(
1038 scales: &ScaleSet,
1039 aes: &Aesthetic,
1040 scale: &dyn crate::scale::Scale,
1041 theme: &Theme,
1042 legend_x: f64,
1043 legend_y: f64,
1044 backend: &mut dyn DrawBackend,
1045 guide: &GuideLegend,
1046 plan: &LegendPlan,
1047) -> Result<f64, RenderError> {
1048 let mut breaks = scale.breaks();
1049 if breaks.is_empty() {
1050 return Ok(0.0);
1051 }
1052 if guide.reverse {
1053 breaks.reverse();
1054 }
1055
1056 let swatch_size = theme.legend_key_width;
1057 let item_height = theme.legend_key_height;
1058
1059 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1060 let legend_family = if theme.legend_title.family.is_empty() {
1061 None
1062 } else {
1063 Some(theme.legend_title.family.clone())
1064 };
1065 let title_offset = if !title.is_empty() {
1066 backend.draw_text(
1067 title,
1068 (legend_x, legend_y),
1069 &TextStyle {
1070 color: theme.legend_title.color,
1071 size: theme.legend_title.size,
1072 anchor: TextAnchor::Start,
1073 angle: 0.0,
1074 family: legend_family,
1075 face: theme.legend_title.face,
1076 },
1077 )?;
1078 theme.legend_title.size + 4.0
1079 } else {
1080 0.0
1081 };
1082 let items_y = legend_y + title_offset;
1083
1084 for entry in &plan.entries {
1085 let (pos, label) = &breaks[entry.break_idx];
1086 let x = legend_x + entry.x_offset;
1087 let y = items_y + entry.row as f64 * item_height;
1088 let center_x = x + swatch_size / 2.0;
1089 let center_y = y + swatch_size / 2.0;
1090
1091 if theme.legend_key.visible {
1092 if let Some(fill) = theme.legend_key.fill {
1093 backend.draw_rect(
1094 (x, y),
1095 (x + swatch_size, y + swatch_size),
1096 &RectStyle {
1097 fill: Some(fill),
1098 stroke: theme.legend_key.color,
1099 stroke_width: theme.legend_key.width,
1100 alpha: 1.0,
1101 clip: false,
1102 },
1103 )?;
1104 }
1105 }
1106
1107 draw_legend_swatch(
1108 backend,
1109 scales,
1110 aes,
1111 scale,
1112 theme,
1113 label,
1114 *pos,
1115 x,
1116 y,
1117 center_x,
1118 center_y,
1119 swatch_size,
1120 )?;
1121
1122 let label_family = if theme.legend_text.family.is_empty() {
1123 None
1124 } else {
1125 Some(theme.legend_text.family.clone())
1126 };
1127 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1131 backend.draw_text_with_title(
1132 display,
1133 (x + swatch_size + theme.legend_spacing, center_y),
1134 &TextStyle {
1135 color: theme.legend_text.color,
1136 size: theme.legend_text.size,
1137 anchor: TextAnchor::Start,
1138 angle: 0.0,
1139 family: label_family,
1140 face: theme.legend_text.face,
1141 },
1142 entry.label_override.as_deref().map(|_| label.as_str()),
1143 )?;
1144 }
1145
1146 if let Some(ov) = &plan.overflow {
1148 let x = legend_x + ov.x_offset;
1149 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1150 let label_family = if theme.legend_text.family.is_empty() {
1151 None
1152 } else {
1153 Some(theme.legend_text.family.clone())
1154 };
1155 backend.draw_text(
1156 &ov.text,
1157 (x, y),
1158 &TextStyle {
1159 color: theme.legend_text.color,
1160 size: theme.legend_text.size,
1161 anchor: TextAnchor::Start,
1162 angle: 0.0,
1163 family: label_family,
1164 face: theme.legend_text.face,
1165 },
1166 )?;
1167 }
1168
1169 Ok(title_offset + plan.rows as f64 * item_height)
1170}