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 corner_radius: 0.0,
264 },
265 )?;
266 }
267 }
268
269 let mut cur_x = legend_x;
271 for (i, (pos, label)) in breaks.iter().enumerate() {
272 let x = if is_horizontal { cur_x } else { legend_x };
273 let y = if is_horizontal {
274 items_y
275 } else {
276 items_y + i as f64 * item_height
277 };
278 let center_x = x + swatch_size / 2.0;
279 let center_y = y + swatch_size / 2.0;
280
281 if theme.legend_key.visible {
283 if let Some(fill) = theme.legend_key.fill {
284 backend.draw_rect(
285 (x, y),
286 (x + swatch_size, y + swatch_size),
287 &RectStyle {
288 fill: Some(fill),
289 stroke: theme.legend_key.color,
290 stroke_width: theme.legend_key.width,
291 alpha: 1.0,
292 clip: false,
293 corner_radius: 0.0,
294 },
295 )?;
296 }
297 }
298
299 draw_legend_swatch(
301 backend,
302 scales,
303 aes,
304 scale,
305 theme,
306 label,
307 *pos,
308 x,
309 y,
310 center_x,
311 center_y,
312 swatch_size,
313 )?;
314
315 let label_family = if theme.legend_text.family.is_empty() {
317 None
318 } else {
319 Some(theme.legend_text.family.clone())
320 };
321 backend.draw_text(
322 label,
323 (x + swatch_size + theme.legend_spacing, center_y),
324 &TextStyle {
325 color: theme.legend_text.color,
326 size: theme.legend_text.size,
327 anchor: TextAnchor::Start,
328 angle: 0.0,
329 family: label_family,
330 face: theme.legend_text.face,
331 },
332 )?;
333 if is_horizontal {
334 cur_x = x
336 + swatch_size
337 + theme.legend_spacing
338 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
339 + theme.legend_spacing * 3.0;
340 }
341 }
342
343 if is_horizontal {
344 Ok(cur_x - legend_x) } else {
346 Ok(title_offset + breaks.len() as f64 * item_height)
347 }
348}
349
350fn draw_continuous_legend_at(
352 scale: &dyn crate::scale::Scale,
353 theme: &Theme,
354 legend_x: f64,
355 legend_y: f64,
356 backend: &mut dyn DrawBackend,
357 guide: &GuideLegend,
358) -> Result<f64, RenderError> {
359 let breaks = scale.breaks();
360 if breaks.is_empty() {
361 return Ok(0.0);
362 }
363
364 let bar_width = theme.legend_key_width;
365 let bar_height = theme.legend_key_height * 8.0;
366
367 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
369 let cont_family = if theme.legend_title.family.is_empty() {
370 None
371 } else {
372 Some(theme.legend_title.family.clone())
373 };
374 let title_offset = if !title.is_empty() {
375 backend.draw_text(
376 title,
377 (legend_x, legend_y),
378 &TextStyle {
379 color: theme.legend_title.color,
380 size: theme.legend_title.size,
381 anchor: TextAnchor::Start,
382 angle: 0.0,
383 family: cont_family,
384 face: theme.legend_title.face,
385 },
386 )?;
387 theme.legend_title.size + 4.0
388 } else {
389 0.0
390 };
391
392 let bar_top = legend_y + title_offset;
393
394 if theme.legend_background.visible {
396 let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
397 if let Some(fill) = theme.legend_background.fill {
398 backend.draw_rect(
399 (legend_x - 2.0, bar_top - 2.0),
400 (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
401 &RectStyle {
402 fill: Some(fill),
403 stroke: theme.legend_background.color,
404 stroke_width: theme.legend_background.width,
405 alpha: 1.0,
406 clip: false,
407 corner_radius: 0.0,
408 },
409 )?;
410 }
411 }
412
413 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
416 let n_slices = 50;
417 let slice_height = bar_height / n_slices as f64;
418 for i in 0..n_slices {
419 let t = 1.0 - i as f64 / n_slices as f64;
420 let data_val = data_min + t * (data_max - data_min);
421 let color = scale
422 .map_to_color(&Value::Float(data_val))
423 .unwrap_or((127, 127, 127));
424 let sy = bar_top + i as f64 * slice_height;
425 backend.draw_rect(
426 (legend_x, sy),
427 (legend_x + bar_width, sy + slice_height + 0.5),
428 &RectStyle {
429 fill: Some(color),
430 stroke: None,
431 stroke_width: 0.0,
432 alpha: 1.0,
433 clip: false,
434 corner_radius: 0.0,
435 },
436 )?;
437 }
438
439 let border = theme.legend_bar_border_line();
442 let border_style = LineStyle {
443 color: border.color,
444 width: border.width,
445 alpha: 1.0,
446 linetype: border.linetype,
447 ..Default::default()
448 };
449 if border.visible {
450 backend.draw_line(
451 &[
452 (legend_x, bar_top),
453 (legend_x + bar_width, bar_top),
454 (legend_x + bar_width, bar_top + bar_height),
455 (legend_x, bar_top + bar_height),
456 (legend_x, bar_top),
457 ],
458 &border_style,
459 )?;
460 }
461
462 let tick_len = 3.0;
464 for (pos, label) in &breaks {
465 let tick_y = bar_top + bar_height * (1.0 - pos);
466 backend.draw_line(
467 &[
468 (legend_x + bar_width, tick_y),
469 (legend_x + bar_width + tick_len, tick_y),
470 ],
471 &border_style,
472 )?;
473 let tick_family = if theme.legend_text.family.is_empty() {
474 None
475 } else {
476 Some(theme.legend_text.family.clone())
477 };
478 backend.draw_text(
479 label,
480 (
481 legend_x + bar_width + tick_len + theme.legend_spacing,
482 tick_y,
483 ),
484 &TextStyle {
485 color: theme.legend_text.color,
486 size: theme.legend_text.size,
487 anchor: TextAnchor::Start,
488 angle: 0.0,
489 family: tick_family,
490 face: theme.legend_text.face,
491 },
492 )?;
493 }
494
495 Ok(title_offset + bar_height)
496}
497
498#[allow(clippy::too_many_arguments)]
504fn draw_legend_swatch(
505 backend: &mut dyn DrawBackend,
506 scales: &ScaleSet,
507 aes: &Aesthetic,
508 scale: &dyn crate::scale::Scale,
509 theme: &Theme,
510 label: &str,
511 pos: f64,
512 x: f64,
513 y: f64,
514 center_x: f64,
515 center_y: f64,
516 swatch_size: f64,
517) -> Result<(), RenderError> {
518 let value = Value::Str(label.to_string());
519 match aes {
520 Aesthetic::Color | Aesthetic::Fill => {
521 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
522 backend.draw_rect(
523 (x, y),
524 (x + swatch_size, y + swatch_size),
525 &RectStyle {
526 fill: Some(color),
527 stroke: None,
528 stroke_width: 0.0,
529 alpha: 1.0,
530 clip: false,
531 corner_radius: 0.0,
532 },
533 )?;
534 }
535 Aesthetic::Shape => {
536 let shape = scales
537 .map_shape(&value)
538 .unwrap_or(crate::render::backend::PointShape::Circle);
539 backend.draw_shape(
540 (center_x, center_y),
541 swatch_size / 3.0,
542 &PointStyle {
543 color: theme.geom.ink.unwrap_or((50, 50, 50)),
544 alpha: 1.0,
545 filled: true,
546 shape,
547 },
548 )?;
549 }
550 Aesthetic::Linetype => {
551 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
552 backend.draw_line(
553 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
554 &LineStyle {
555 color: theme.geom.ink.unwrap_or((50, 50, 50)),
556 width: 1.5,
557 alpha: 1.0,
558 linetype: lt,
559 ..Default::default()
560 },
561 )?;
562 }
563 Aesthetic::Size => {
564 let size = scale
565 .map_fraction_to_size(pos)
566 .or_else(|| scales.map_size(&value))
567 .unwrap_or(3.0);
568 backend.draw_shape(
569 (center_x, center_y),
570 size.min(swatch_size / 2.0),
571 &PointStyle {
572 color: theme.geom.ink.unwrap_or((50, 50, 50)),
573 alpha: 1.0,
574 filled: true,
575 shape: crate::render::backend::PointShape::Circle,
576 },
577 )?;
578 }
579 Aesthetic::Alpha => {
580 let alpha = scale
581 .map_fraction_to_alpha(pos)
582 .or_else(|| scales.map_alpha(&value))
583 .unwrap_or(1.0);
584 backend.draw_rect(
585 (x, y),
586 (x + swatch_size, y + swatch_size),
587 &RectStyle {
588 fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
589 stroke: None,
590 stroke_width: 0.0,
591 alpha,
592 clip: false,
593 corner_radius: 0.0,
594 },
595 )?;
596 }
597 Aesthetic::Linewidth => {
598 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
599 backend.draw_line(
600 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
601 &LineStyle {
602 color: theme.geom.ink.unwrap_or((50, 50, 50)),
603 width: width.min(swatch_size),
604 alpha: 1.0,
605 linetype: Linetype::Solid,
606 ..Default::default()
607 },
608 )?;
609 }
610 _ => {}
611 }
612 Ok(())
613}
614
615#[derive(Clone, Debug)]
621pub(crate) struct LegendEntry {
622 pub break_idx: usize,
623 pub row: usize,
624 pub x_offset: f64,
625 pub label_override: Option<String>,
632}
633
634#[derive(Clone, Debug)]
638pub(crate) struct LegendOverflow {
639 pub dropped: usize,
640 pub row: usize,
641 pub x_offset: f64,
642 pub text: String,
643}
644
645#[derive(Clone, Debug)]
650pub(crate) struct LegendPlan {
651 pub rows: usize,
652 pub entries: Vec<LegendEntry>,
653 pub overflow: Option<LegendOverflow>,
654 pub cols: usize,
659 pub col_pitch: f64,
662}
663
664fn legend_pitch(label: &str, theme: &Theme) -> f64 {
669 let swatch = theme.legend_key_width;
670 let spacing = theme.legend_spacing;
671 let text_size = theme.legend_text.size;
672 let legacy =
673 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
674 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
675 legacy.max(measured)
676}
677
678pub(crate) fn plan_legend(
686 breaks: &[(f64, String)],
687 guide: &GuideLegend,
688 theme: &Theme,
689 avail_width: f64,
690 avail_height: f64,
691 is_horizontal: bool,
692) -> Option<LegendPlan> {
693 let n = breaks.len();
694 if n == 0 {
695 return None;
696 }
697
698 if guide.nrow.is_some() || guide.ncol.is_some() {
700 let (rows, cols) = match (guide.nrow, guide.ncol) {
701 (Some(nrow), Some(ncol)) => (nrow, ncol),
702 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
703 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
704 (None, None) => unreachable!("checked above"),
705 };
706 let rows = rows.max(1);
707 let cols = cols.max(1);
708 let col_pitch = breaks
709 .iter()
710 .map(|(_, l)| legend_pitch(l, theme))
711 .fold(0.0_f64, f64::max);
712 let entries = (0..n)
713 .map(|i| {
714 let (row, col) = if guide.byrow {
715 (i / cols, i % cols)
716 } else {
717 (i % rows, i / rows)
718 };
719 LegendEntry {
720 break_idx: i,
721 row,
722 x_offset: col as f64 * col_pitch,
723 label_override: None,
724 }
725 })
726 .collect();
727 return Some(LegendPlan {
728 rows,
729 entries,
730 overflow: None,
731 cols,
732 col_pitch,
733 });
734 }
735
736 if !is_horizontal {
738 return None;
739 }
740
741 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
742 let total_width: f64 = pitches.iter().sum();
743 if total_width <= avail_width.max(0.0) {
744 return None; }
746
747 let item_height = theme.legend_key_height.max(1.0);
748 let row_limit = guide
749 .max_rows
750 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
751 .max(1);
752
753 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
754 let mut row = 0usize;
755 let mut cur_x = 0.0_f64;
756 for (i, &p) in pitches.iter().enumerate() {
757 if cur_x > 0.0 && cur_x + p > avail_width {
758 if row + 1 >= row_limit {
759 break; }
761 row += 1;
762 cur_x = 0.0;
763 }
764 entries.push(LegendEntry {
765 break_idx: i,
766 row,
767 x_offset: cur_x,
768 label_override: None,
769 });
770 cur_x += p;
771 }
772
773 if entries.len() >= n {
774 return Some(LegendPlan {
775 rows: row + 1,
776 entries,
777 overflow: None,
778 cols: 1,
779 col_pitch: 0.0,
780 });
781 }
782
783 let mut dropped = n - entries.len();
786 loop {
787 let text = format!("+{dropped} more");
788 let more_pitch = legend_pitch(&text, theme);
789 let row_count = entries.iter().filter(|e| e.row == row).count();
790 let last_x = entries
791 .iter()
792 .filter(|e| e.row == row)
793 .map(|e| e.x_offset + pitches[e.break_idx])
794 .fold(0.0_f64, f64::max);
795 if last_x + more_pitch <= avail_width || row_count == 0 {
796 return Some(LegendPlan {
797 rows: row + 1,
798 entries,
799 overflow: Some(LegendOverflow {
800 dropped,
801 row,
802 x_offset: last_x,
803 text,
804 }),
805 cols: 1,
806 col_pitch: 0.0,
807 });
808 }
809 entries.pop();
810 dropped += 1;
811 }
812}
813
814fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
818 legend_scales
819 .iter()
820 .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
821 .count()
822 .max(1)
823}
824
825fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
832 legend_scales
833 .iter()
834 .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
835 .count()
836}
837
838fn continuous_guide_width(theme: &Theme) -> f64 {
843 theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
844}
845
846#[allow(clippy::too_many_arguments)]
860pub(crate) fn plan_all_legends(
861 scales: &ScaleSet,
862 guide: &GuideLegend,
863 theme: &Theme,
864 suppressed: &std::collections::HashSet<Aesthetic>,
865 avail_width: f64,
866 avail_height: f64,
867 is_horizontal: bool,
868 warnings: &mut Vec<String>,
869) -> std::collections::HashMap<Aesthetic, LegendPlan> {
870 let legend_scales = legend_scales_for(scales, suppressed);
871 let n_discrete = discrete_guide_count(scales, &legend_scales);
872 let per_guide_width = if is_horizontal {
873 let n_continuous = continuous_guide_count(scales, &legend_scales);
879 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
880 ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
881 } else {
882 avail_width
883 };
884
885 let mut plans = std::collections::HashMap::new();
886 for aes in &legend_scales {
887 let Some(scale) = scales.get(aes) else {
888 continue;
889 };
890 if !scale.is_discrete() {
891 continue;
892 }
893 let breaks = scale.breaks();
894 let validated_guide = guide.validated(breaks.len(), warnings);
895 if let Some(plan) = plan_legend(
896 &breaks,
897 &validated_guide,
898 theme,
899 per_guide_width,
900 avail_height,
901 is_horizontal,
902 ) {
903 plans.insert(aes.clone(), plan);
904 }
905 }
906 plans
907}
908
909#[allow(clippy::too_many_arguments)]
919pub(crate) fn draw_legend_planned(
920 scales: &ScaleSet,
921 theme: &Theme,
922 plot_area: &Rect,
923 legend_avail_width: f64,
933 backend: &mut dyn DrawBackend,
934 guide: &GuideLegend,
935 suppressed: &std::collections::HashSet<Aesthetic>,
936 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
942) -> Result<(), RenderError> {
943 if matches!(theme.legend_position, LegendPosition::None) {
944 return Ok(());
945 }
946
947 let legend_scales = legend_scales_for(scales, suppressed);
948 if legend_scales.is_empty() {
949 return Ok(());
950 }
951
952 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
953 if let Some(dir) = theme.legend_direction {
954 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
955 }
956 let n_discrete = discrete_guide_count(scales, &legend_scales);
957 let n_continuous = continuous_guide_count(scales, &legend_scales);
962 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
963 let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
964
965 let mut offset_y = legend_y;
966 let mut offset_x = legend_x;
967
968 for aes in &legend_scales {
969 let scale = scales.get(aes).unwrap();
970 if !scale.is_discrete() {
971 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
974 let height =
975 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
976 if is_horizontal {
977 offset_x += theme.legend_key_width
978 + theme.legend_text.size * 6.0
979 + theme.legend_spacing * 2.0;
980 } else {
981 offset_y += height + theme.legend_spacing * 2.0;
982 }
983 } else {
984 let height = draw_discrete_legend_at(
985 scales,
986 aes,
987 scale,
988 theme,
989 offset_x,
990 offset_y,
991 backend,
992 guide,
993 is_horizontal,
994 )?;
995 if is_horizontal {
996 offset_x += theme.legend_key_width
997 + theme.legend_text.size * 6.0
998 + theme.legend_spacing * 2.0;
999 } else {
1000 offset_y += height + theme.legend_spacing * 2.0;
1001 }
1002 }
1003 continue;
1004 }
1005
1006 match legend_plans.get(aes) {
1007 None => {
1008 if is_horizontal {
1009 let width = draw_discrete_legend_at(
1010 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1011 )?;
1012 offset_x += width + theme.legend_spacing * 2.0;
1013 } else {
1014 let height = draw_discrete_legend_at(
1015 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1016 )?;
1017 offset_y += height + theme.legend_spacing * 2.0;
1018 }
1019 }
1020 Some(plan) => {
1021 let height = draw_discrete_legend_grid(
1022 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1023 )?;
1024 if is_horizontal {
1025 offset_x += per_guide_width;
1029 } else {
1030 offset_y += height + theme.legend_spacing * 2.0;
1031 }
1032 }
1033 }
1034 }
1035
1036 Ok(())
1037}
1038
1039#[allow(clippy::too_many_arguments)]
1043fn draw_discrete_legend_grid(
1044 scales: &ScaleSet,
1045 aes: &Aesthetic,
1046 scale: &dyn crate::scale::Scale,
1047 theme: &Theme,
1048 legend_x: f64,
1049 legend_y: f64,
1050 backend: &mut dyn DrawBackend,
1051 guide: &GuideLegend,
1052 plan: &LegendPlan,
1053) -> Result<f64, RenderError> {
1054 let mut breaks = scale.breaks();
1055 if breaks.is_empty() {
1056 return Ok(0.0);
1057 }
1058 if guide.reverse {
1059 breaks.reverse();
1060 }
1061
1062 let swatch_size = theme.legend_key_width;
1063 let item_height = theme.legend_key_height;
1064
1065 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1066 let legend_family = if theme.legend_title.family.is_empty() {
1067 None
1068 } else {
1069 Some(theme.legend_title.family.clone())
1070 };
1071 let title_offset = if !title.is_empty() {
1072 backend.draw_text(
1073 title,
1074 (legend_x, legend_y),
1075 &TextStyle {
1076 color: theme.legend_title.color,
1077 size: theme.legend_title.size,
1078 anchor: TextAnchor::Start,
1079 angle: 0.0,
1080 family: legend_family,
1081 face: theme.legend_title.face,
1082 },
1083 )?;
1084 theme.legend_title.size + 4.0
1085 } else {
1086 0.0
1087 };
1088 let items_y = legend_y + title_offset;
1089
1090 for entry in &plan.entries {
1091 let (pos, label) = &breaks[entry.break_idx];
1092 let x = legend_x + entry.x_offset;
1093 let y = items_y + entry.row as f64 * item_height;
1094 let center_x = x + swatch_size / 2.0;
1095 let center_y = y + swatch_size / 2.0;
1096
1097 if theme.legend_key.visible {
1098 if let Some(fill) = theme.legend_key.fill {
1099 backend.draw_rect(
1100 (x, y),
1101 (x + swatch_size, y + swatch_size),
1102 &RectStyle {
1103 fill: Some(fill),
1104 stroke: theme.legend_key.color,
1105 stroke_width: theme.legend_key.width,
1106 alpha: 1.0,
1107 clip: false,
1108 corner_radius: 0.0,
1109 },
1110 )?;
1111 }
1112 }
1113
1114 draw_legend_swatch(
1115 backend,
1116 scales,
1117 aes,
1118 scale,
1119 theme,
1120 label,
1121 *pos,
1122 x,
1123 y,
1124 center_x,
1125 center_y,
1126 swatch_size,
1127 )?;
1128
1129 let label_family = if theme.legend_text.family.is_empty() {
1130 None
1131 } else {
1132 Some(theme.legend_text.family.clone())
1133 };
1134 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1138 backend.draw_text_with_title(
1139 display,
1140 (x + swatch_size + theme.legend_spacing, center_y),
1141 &TextStyle {
1142 color: theme.legend_text.color,
1143 size: theme.legend_text.size,
1144 anchor: TextAnchor::Start,
1145 angle: 0.0,
1146 family: label_family,
1147 face: theme.legend_text.face,
1148 },
1149 entry.label_override.as_deref().map(|_| label.as_str()),
1150 )?;
1151 }
1152
1153 if let Some(ov) = &plan.overflow {
1155 let x = legend_x + ov.x_offset;
1156 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1157 let label_family = if theme.legend_text.family.is_empty() {
1158 None
1159 } else {
1160 Some(theme.legend_text.family.clone())
1161 };
1162 backend.draw_text(
1163 &ov.text,
1164 (x, y),
1165 &TextStyle {
1166 color: theme.legend_text.color,
1167 size: theme.legend_text.size,
1168 anchor: TextAnchor::Start,
1169 angle: 0.0,
1170 family: label_family,
1171 face: theme.legend_text.face,
1172 },
1173 )?;
1174 }
1175
1176 Ok(title_offset + plan.rows as f64 * item_height)
1177}