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
48pub fn draw_legend(
50 scales: &ScaleSet,
51 theme: &Theme,
52 plot_area: &Rect,
53 backend: &mut dyn DrawBackend,
54 guide: &GuideLegend,
55 suppressed: &std::collections::HashSet<Aesthetic>,
56) -> Result<(), RenderError> {
57 if matches!(theme.legend_position, LegendPosition::None) {
58 return Ok(());
59 }
60
61 let mut legend_scales: Vec<&Aesthetic> = Vec::new();
63 for aes in LEGEND_AESTHETICS {
64 if suppressed.contains(aes) {
66 continue;
67 }
68 if let Some(scale) = scales.get(aes) {
69 if !scale.breaks().is_empty() {
70 if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
72 continue;
73 }
74 legend_scales.push(aes);
75 }
76 }
77 }
78
79 if legend_scales.is_empty() {
80 return Ok(());
81 }
82
83 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
85 if let Some(dir) = theme.legend_direction {
87 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
88 }
89
90 let mut offset_y = legend_y;
91 let mut offset_x = legend_x;
92
93 for aes in &legend_scales {
94 let scale = scales.get(aes).unwrap();
95
96 if scale.is_discrete() {
97 if is_horizontal {
98 let width = draw_discrete_legend_at(
99 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
100 )?;
101 offset_x += width + theme.legend_spacing * 2.0;
102 } else {
103 let height = draw_discrete_legend_at(
104 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
105 )?;
106 offset_y += height + theme.legend_spacing * 2.0;
107 }
108 } else {
109 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
111 let height =
112 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
113 if is_horizontal {
114 offset_x += theme.legend_key_width
115 + theme.legend_text.size * 6.0
116 + theme.legend_spacing * 2.0;
117 } else {
118 offset_y += height + theme.legend_spacing * 2.0;
119 }
120 } else {
121 let height = draw_discrete_legend_at(
123 scales,
124 aes,
125 scale,
126 theme,
127 offset_x,
128 offset_y,
129 backend,
130 guide,
131 is_horizontal,
132 )?;
133 if is_horizontal {
134 offset_x += theme.legend_key_width
135 + theme.legend_text.size * 6.0
136 + theme.legend_spacing * 2.0;
137 } else {
138 offset_y += height + theme.legend_spacing * 2.0;
139 }
140 }
141 }
142 }
143
144 Ok(())
145}
146
147pub(crate) fn legend_position(theme: &Theme, plot_area: &Rect) -> (f64, f64, bool) {
150 match theme.legend_position {
151 LegendPosition::Right => (
152 plot_area.x + plot_area.width + theme.legend_margin.left,
153 plot_area.y + theme.legend_margin.top,
154 false,
155 ),
156 LegendPosition::Left => (
157 theme.legend_margin.left,
158 plot_area.y + theme.legend_margin.top,
159 false,
160 ),
161 LegendPosition::Top => (
162 plot_area.x + theme.legend_margin.left,
163 theme.legend_margin.top,
164 true,
165 ),
166 LegendPosition::Bottom => (
167 plot_area.x + theme.legend_margin.left,
168 plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
169 true,
170 ),
171 LegendPosition::None => (0.0, 0.0, false),
172 LegendPosition::Inside(fx, fy) => (
173 plot_area.x + fx * plot_area.width,
174 plot_area.y + (1.0 - fy) * plot_area.height,
175 false,
176 ),
177 }
178}
179
180#[allow(clippy::too_many_arguments)]
182fn draw_discrete_legend_at(
183 scales: &ScaleSet,
184 aes: &Aesthetic,
185 scale: &dyn crate::scale::Scale,
186 theme: &Theme,
187 legend_x: f64,
188 legend_y: f64,
189 backend: &mut dyn DrawBackend,
190 guide: &GuideLegend,
191 is_horizontal: bool,
192) -> Result<f64, RenderError> {
193 let mut breaks = scale.breaks();
194 if breaks.is_empty() {
195 return Ok(0.0);
196 }
197
198 if guide.reverse {
200 breaks.reverse();
201 }
202
203 let item_height = theme.legend_key_height;
204 let swatch_size = theme.legend_key_width;
205
206 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
208 let legend_family = if theme.legend_title.family.is_empty() {
209 None
210 } else {
211 Some(theme.legend_title.family.clone())
212 };
213 let title_offset = if !title.is_empty() {
214 backend.draw_text(
215 title,
216 (legend_x, legend_y),
217 &TextStyle {
218 color: theme.legend_title.color,
219 size: theme.legend_title.size,
220 anchor: TextAnchor::Start,
221 angle: 0.0,
222 family: legend_family,
223 face: theme.legend_title.face,
224 },
225 )?;
226 theme.legend_title.size + 4.0
227 } else {
228 0.0
229 };
230
231 let items_y = legend_y + title_offset;
232
233 if theme.legend_background.visible {
235 let total_height = breaks.len() as f64 * item_height;
236 let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
237 if let Some(fill) = theme.legend_background.fill {
238 backend.draw_rect(
239 (legend_x - 2.0, items_y - 2.0),
240 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
241 &RectStyle {
242 fill: Some(fill),
243 stroke: theme.legend_background.color,
244 stroke_width: theme.legend_background.width,
245 alpha: 1.0,
246 clip: false,
247 },
248 )?;
249 }
250 }
251
252 let mut cur_x = legend_x;
254 for (i, (pos, label)) in breaks.iter().enumerate() {
255 let x = if is_horizontal { cur_x } else { legend_x };
256 let y = if is_horizontal {
257 items_y
258 } else {
259 items_y + i as f64 * item_height
260 };
261 let center_x = x + swatch_size / 2.0;
262 let center_y = y + swatch_size / 2.0;
263
264 if theme.legend_key.visible {
266 if let Some(fill) = theme.legend_key.fill {
267 backend.draw_rect(
268 (x, y),
269 (x + swatch_size, y + swatch_size),
270 &RectStyle {
271 fill: Some(fill),
272 stroke: theme.legend_key.color,
273 stroke_width: theme.legend_key.width,
274 alpha: 1.0,
275 clip: false,
276 },
277 )?;
278 }
279 }
280
281 draw_legend_swatch(
283 backend,
284 scales,
285 aes,
286 scale,
287 theme,
288 label,
289 *pos,
290 x,
291 y,
292 center_x,
293 center_y,
294 swatch_size,
295 )?;
296
297 let label_family = if theme.legend_text.family.is_empty() {
299 None
300 } else {
301 Some(theme.legend_text.family.clone())
302 };
303 backend.draw_text(
304 label,
305 (x + swatch_size + theme.legend_spacing, center_y),
306 &TextStyle {
307 color: theme.legend_text.color,
308 size: theme.legend_text.size,
309 anchor: TextAnchor::Start,
310 angle: 0.0,
311 family: label_family,
312 face: theme.legend_text.face,
313 },
314 )?;
315 if is_horizontal {
316 cur_x = x
318 + swatch_size
319 + theme.legend_spacing
320 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
321 + theme.legend_spacing * 3.0;
322 }
323 }
324
325 if is_horizontal {
326 Ok(cur_x - legend_x) } else {
328 Ok(title_offset + breaks.len() as f64 * item_height)
329 }
330}
331
332fn draw_continuous_legend_at(
334 scale: &dyn crate::scale::Scale,
335 theme: &Theme,
336 legend_x: f64,
337 legend_y: f64,
338 backend: &mut dyn DrawBackend,
339 guide: &GuideLegend,
340) -> Result<f64, RenderError> {
341 let breaks = scale.breaks();
342 if breaks.is_empty() {
343 return Ok(0.0);
344 }
345
346 let bar_width = theme.legend_key_width;
347 let bar_height = theme.legend_key_height * 8.0;
348
349 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
351 let cont_family = if theme.legend_title.family.is_empty() {
352 None
353 } else {
354 Some(theme.legend_title.family.clone())
355 };
356 let title_offset = if !title.is_empty() {
357 backend.draw_text(
358 title,
359 (legend_x, legend_y),
360 &TextStyle {
361 color: theme.legend_title.color,
362 size: theme.legend_title.size,
363 anchor: TextAnchor::Start,
364 angle: 0.0,
365 family: cont_family,
366 face: theme.legend_title.face,
367 },
368 )?;
369 theme.legend_title.size + 4.0
370 } else {
371 0.0
372 };
373
374 let bar_top = legend_y + title_offset;
375
376 if theme.legend_background.visible {
378 let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
379 if let Some(fill) = theme.legend_background.fill {
380 backend.draw_rect(
381 (legend_x - 2.0, bar_top - 2.0),
382 (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
383 &RectStyle {
384 fill: Some(fill),
385 stroke: theme.legend_background.color,
386 stroke_width: theme.legend_background.width,
387 alpha: 1.0,
388 clip: false,
389 },
390 )?;
391 }
392 }
393
394 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
397 let n_slices = 50;
398 let slice_height = bar_height / n_slices as f64;
399 for i in 0..n_slices {
400 let t = 1.0 - i as f64 / n_slices as f64;
401 let data_val = data_min + t * (data_max - data_min);
402 let color = scale
403 .map_to_color(&Value::Float(data_val))
404 .unwrap_or((127, 127, 127));
405 let sy = bar_top + i as f64 * slice_height;
406 backend.draw_rect(
407 (legend_x, sy),
408 (legend_x + bar_width, sy + slice_height + 0.5),
409 &RectStyle {
410 fill: Some(color),
411 stroke: None,
412 stroke_width: 0.0,
413 alpha: 1.0,
414 clip: false,
415 },
416 )?;
417 }
418
419 let border = theme.legend_bar_border_line();
422 let border_style = LineStyle {
423 color: border.color,
424 width: border.width,
425 alpha: 1.0,
426 linetype: border.linetype,
427 ..Default::default()
428 };
429 if border.visible {
430 backend.draw_line(
431 &[
432 (legend_x, bar_top),
433 (legend_x + bar_width, bar_top),
434 (legend_x + bar_width, bar_top + bar_height),
435 (legend_x, bar_top + bar_height),
436 (legend_x, bar_top),
437 ],
438 &border_style,
439 )?;
440 }
441
442 let tick_len = 3.0;
444 for (pos, label) in &breaks {
445 let tick_y = bar_top + bar_height * (1.0 - pos);
446 backend.draw_line(
447 &[
448 (legend_x + bar_width, tick_y),
449 (legend_x + bar_width + tick_len, tick_y),
450 ],
451 &border_style,
452 )?;
453 let tick_family = if theme.legend_text.family.is_empty() {
454 None
455 } else {
456 Some(theme.legend_text.family.clone())
457 };
458 backend.draw_text(
459 label,
460 (
461 legend_x + bar_width + tick_len + theme.legend_spacing,
462 tick_y,
463 ),
464 &TextStyle {
465 color: theme.legend_text.color,
466 size: theme.legend_text.size,
467 anchor: TextAnchor::Start,
468 angle: 0.0,
469 family: tick_family,
470 face: theme.legend_text.face,
471 },
472 )?;
473 }
474
475 Ok(title_offset + bar_height)
476}
477
478#[allow(clippy::too_many_arguments)]
484fn draw_legend_swatch(
485 backend: &mut dyn DrawBackend,
486 scales: &ScaleSet,
487 aes: &Aesthetic,
488 scale: &dyn crate::scale::Scale,
489 theme: &Theme,
490 label: &str,
491 pos: f64,
492 x: f64,
493 y: f64,
494 center_x: f64,
495 center_y: f64,
496 swatch_size: f64,
497) -> Result<(), RenderError> {
498 let value = Value::Str(label.to_string());
499 match aes {
500 Aesthetic::Color | Aesthetic::Fill => {
501 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
502 backend.draw_rect(
503 (x, y),
504 (x + swatch_size, y + swatch_size),
505 &RectStyle {
506 fill: Some(color),
507 stroke: None,
508 stroke_width: 0.0,
509 alpha: 1.0,
510 clip: false,
511 },
512 )?;
513 }
514 Aesthetic::Shape => {
515 let shape = scales
516 .map_shape(&value)
517 .unwrap_or(crate::render::backend::PointShape::Circle);
518 backend.draw_shape(
519 (center_x, center_y),
520 swatch_size / 3.0,
521 &PointStyle {
522 color: theme.geom.ink.unwrap_or((50, 50, 50)),
523 alpha: 1.0,
524 filled: true,
525 shape,
526 },
527 )?;
528 }
529 Aesthetic::Linetype => {
530 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
531 backend.draw_line(
532 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
533 &LineStyle {
534 color: theme.geom.ink.unwrap_or((50, 50, 50)),
535 width: 1.5,
536 alpha: 1.0,
537 linetype: lt,
538 ..Default::default()
539 },
540 )?;
541 }
542 Aesthetic::Size => {
543 let size = scale
544 .map_fraction_to_size(pos)
545 .or_else(|| scales.map_size(&value))
546 .unwrap_or(3.0);
547 backend.draw_shape(
548 (center_x, center_y),
549 size.min(swatch_size / 2.0),
550 &PointStyle {
551 color: theme.geom.ink.unwrap_or((50, 50, 50)),
552 alpha: 1.0,
553 filled: true,
554 shape: crate::render::backend::PointShape::Circle,
555 },
556 )?;
557 }
558 Aesthetic::Alpha => {
559 let alpha = scale
560 .map_fraction_to_alpha(pos)
561 .or_else(|| scales.map_alpha(&value))
562 .unwrap_or(1.0);
563 backend.draw_rect(
564 (x, y),
565 (x + swatch_size, y + swatch_size),
566 &RectStyle {
567 fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
568 stroke: None,
569 stroke_width: 0.0,
570 alpha,
571 clip: false,
572 },
573 )?;
574 }
575 Aesthetic::Linewidth => {
576 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
577 backend.draw_line(
578 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
579 &LineStyle {
580 color: theme.geom.ink.unwrap_or((50, 50, 50)),
581 width: width.min(swatch_size),
582 alpha: 1.0,
583 linetype: Linetype::Solid,
584 ..Default::default()
585 },
586 )?;
587 }
588 _ => {}
589 }
590 Ok(())
591}
592
593#[derive(Clone, Debug)]
599pub(crate) struct LegendEntry {
600 pub break_idx: usize,
601 pub row: usize,
602 pub x_offset: f64,
603}
604
605#[derive(Clone, Debug)]
609pub(crate) struct LegendOverflow {
610 pub dropped: usize,
611 pub row: usize,
612 pub x_offset: f64,
613 pub text: String,
614}
615
616#[derive(Clone, Debug)]
621pub(crate) struct LegendPlan {
622 pub rows: usize,
623 pub entries: Vec<LegendEntry>,
624 pub overflow: Option<LegendOverflow>,
625 pub cols: usize,
630 pub col_pitch: f64,
633}
634
635fn legend_pitch(label: &str, theme: &Theme) -> f64 {
640 let swatch = theme.legend_key_width;
641 let spacing = theme.legend_spacing;
642 let text_size = theme.legend_text.size;
643 let legacy =
644 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
645 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
646 legacy.max(measured)
647}
648
649pub(crate) fn plan_legend(
657 breaks: &[(f64, String)],
658 guide: &GuideLegend,
659 theme: &Theme,
660 avail_width: f64,
661 avail_height: f64,
662 is_horizontal: bool,
663) -> Option<LegendPlan> {
664 let n = breaks.len();
665 if n == 0 {
666 return None;
667 }
668
669 if guide.nrow.is_some() || guide.ncol.is_some() {
671 let (rows, cols) = match (guide.nrow, guide.ncol) {
672 (Some(nrow), Some(ncol)) => (nrow, ncol),
673 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
674 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
675 (None, None) => unreachable!("checked above"),
676 };
677 let rows = rows.max(1);
678 let cols = cols.max(1);
679 let col_pitch = breaks
680 .iter()
681 .map(|(_, l)| legend_pitch(l, theme))
682 .fold(0.0_f64, f64::max);
683 let entries = (0..n)
684 .map(|i| {
685 let (row, col) = if guide.byrow {
686 (i / cols, i % cols)
687 } else {
688 (i % rows, i / rows)
689 };
690 LegendEntry {
691 break_idx: i,
692 row,
693 x_offset: col as f64 * col_pitch,
694 }
695 })
696 .collect();
697 return Some(LegendPlan {
698 rows,
699 entries,
700 overflow: None,
701 cols,
702 col_pitch,
703 });
704 }
705
706 if !is_horizontal {
708 return None;
709 }
710
711 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
712 let total_width: f64 = pitches.iter().sum();
713 if total_width <= avail_width.max(0.0) {
714 return None; }
716
717 let item_height = theme.legend_key_height.max(1.0);
718 let row_limit = guide
719 .max_rows
720 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
721 .max(1);
722
723 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
724 let mut row = 0usize;
725 let mut cur_x = 0.0_f64;
726 for (i, &p) in pitches.iter().enumerate() {
727 if cur_x > 0.0 && cur_x + p > avail_width {
728 if row + 1 >= row_limit {
729 break; }
731 row += 1;
732 cur_x = 0.0;
733 }
734 entries.push(LegendEntry {
735 break_idx: i,
736 row,
737 x_offset: cur_x,
738 });
739 cur_x += p;
740 }
741
742 if entries.len() >= n {
743 return Some(LegendPlan {
744 rows: row + 1,
745 entries,
746 overflow: None,
747 cols: 1,
748 col_pitch: 0.0,
749 });
750 }
751
752 let mut dropped = n - entries.len();
755 loop {
756 let text = format!("+{dropped} more");
757 let more_pitch = legend_pitch(&text, theme);
758 let row_count = entries.iter().filter(|e| e.row == row).count();
759 let last_x = entries
760 .iter()
761 .filter(|e| e.row == row)
762 .map(|e| e.x_offset + pitches[e.break_idx])
763 .fold(0.0_f64, f64::max);
764 if last_x + more_pitch <= avail_width || row_count == 0 {
765 return Some(LegendPlan {
766 rows: row + 1,
767 entries,
768 overflow: Some(LegendOverflow {
769 dropped,
770 row,
771 x_offset: last_x,
772 text,
773 }),
774 cols: 1,
775 col_pitch: 0.0,
776 });
777 }
778 entries.pop();
779 dropped += 1;
780 }
781}
782
783#[allow(clippy::too_many_arguments)]
791pub(crate) fn draw_legend_planned(
792 scales: &ScaleSet,
793 theme: &Theme,
794 plot_area: &Rect,
795 backend: &mut dyn DrawBackend,
796 guide: &GuideLegend,
797 suppressed: &std::collections::HashSet<Aesthetic>,
798 legend_plan: Option<&LegendPlan>,
804) -> Result<(), RenderError> {
805 if matches!(theme.legend_position, LegendPosition::None) {
806 return Ok(());
807 }
808
809 let legend_scales = legend_scales_for(scales, suppressed);
810 if legend_scales.is_empty() {
811 return Ok(());
812 }
813
814 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
815 if let Some(dir) = theme.legend_direction {
816 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
817 }
818
819 let mut offset_y = legend_y;
820 let mut offset_x = legend_x;
821
822 for aes in &legend_scales {
823 let scale = scales.get(aes).unwrap();
824 if !scale.is_discrete() {
825 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
828 let height =
829 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
830 if is_horizontal {
831 offset_x += theme.legend_key_width
832 + theme.legend_text.size * 6.0
833 + theme.legend_spacing * 2.0;
834 } else {
835 offset_y += height + theme.legend_spacing * 2.0;
836 }
837 } else {
838 let height = draw_discrete_legend_at(
839 scales,
840 aes,
841 scale,
842 theme,
843 offset_x,
844 offset_y,
845 backend,
846 guide,
847 is_horizontal,
848 )?;
849 if is_horizontal {
850 offset_x += theme.legend_key_width
851 + theme.legend_text.size * 6.0
852 + theme.legend_spacing * 2.0;
853 } else {
854 offset_y += height + theme.legend_spacing * 2.0;
855 }
856 }
857 continue;
858 }
859
860 if legend_scales.len() != 1 {
864 if is_horizontal {
865 let width = draw_discrete_legend_at(
866 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
867 )?;
868 offset_x += width + theme.legend_spacing * 2.0;
869 } else {
870 let height = draw_discrete_legend_at(
871 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
872 )?;
873 offset_y += height + theme.legend_spacing * 2.0;
874 }
875 continue;
876 }
877
878 match legend_plan {
879 None => {
880 if is_horizontal {
881 let width = draw_discrete_legend_at(
882 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
883 )?;
884 offset_x += width + theme.legend_spacing * 2.0;
885 } else {
886 let height = draw_discrete_legend_at(
887 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
888 )?;
889 offset_y += height + theme.legend_spacing * 2.0;
890 }
891 }
892 Some(plan) => {
893 let height = draw_discrete_legend_grid(
894 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
895 )?;
896 if is_horizontal {
897 offset_x += plot_area.width; } else {
899 offset_y += height + theme.legend_spacing * 2.0;
900 }
901 }
902 }
903 }
904
905 Ok(())
906}
907
908#[allow(clippy::too_many_arguments)]
912fn draw_discrete_legend_grid(
913 scales: &ScaleSet,
914 aes: &Aesthetic,
915 scale: &dyn crate::scale::Scale,
916 theme: &Theme,
917 legend_x: f64,
918 legend_y: f64,
919 backend: &mut dyn DrawBackend,
920 guide: &GuideLegend,
921 plan: &LegendPlan,
922) -> Result<f64, RenderError> {
923 let mut breaks = scale.breaks();
924 if breaks.is_empty() {
925 return Ok(0.0);
926 }
927 if guide.reverse {
928 breaks.reverse();
929 }
930
931 let swatch_size = theme.legend_key_width;
932 let item_height = theme.legend_key_height;
933
934 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
935 let legend_family = if theme.legend_title.family.is_empty() {
936 None
937 } else {
938 Some(theme.legend_title.family.clone())
939 };
940 let title_offset = if !title.is_empty() {
941 backend.draw_text(
942 title,
943 (legend_x, legend_y),
944 &TextStyle {
945 color: theme.legend_title.color,
946 size: theme.legend_title.size,
947 anchor: TextAnchor::Start,
948 angle: 0.0,
949 family: legend_family,
950 face: theme.legend_title.face,
951 },
952 )?;
953 theme.legend_title.size + 4.0
954 } else {
955 0.0
956 };
957 let items_y = legend_y + title_offset;
958
959 for entry in &plan.entries {
960 let (pos, label) = &breaks[entry.break_idx];
961 let x = legend_x + entry.x_offset;
962 let y = items_y + entry.row as f64 * item_height;
963 let center_x = x + swatch_size / 2.0;
964 let center_y = y + swatch_size / 2.0;
965
966 if theme.legend_key.visible {
967 if let Some(fill) = theme.legend_key.fill {
968 backend.draw_rect(
969 (x, y),
970 (x + swatch_size, y + swatch_size),
971 &RectStyle {
972 fill: Some(fill),
973 stroke: theme.legend_key.color,
974 stroke_width: theme.legend_key.width,
975 alpha: 1.0,
976 clip: false,
977 },
978 )?;
979 }
980 }
981
982 draw_legend_swatch(
983 backend,
984 scales,
985 aes,
986 scale,
987 theme,
988 label,
989 *pos,
990 x,
991 y,
992 center_x,
993 center_y,
994 swatch_size,
995 )?;
996
997 let label_family = if theme.legend_text.family.is_empty() {
998 None
999 } else {
1000 Some(theme.legend_text.family.clone())
1001 };
1002 backend.draw_text(
1003 label,
1004 (x + swatch_size + theme.legend_spacing, center_y),
1005 &TextStyle {
1006 color: theme.legend_text.color,
1007 size: theme.legend_text.size,
1008 anchor: TextAnchor::Start,
1009 angle: 0.0,
1010 family: label_family,
1011 face: theme.legend_text.face,
1012 },
1013 )?;
1014 }
1015
1016 if let Some(ov) = &plan.overflow {
1018 let x = legend_x + ov.x_offset;
1019 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1020 let label_family = if theme.legend_text.family.is_empty() {
1021 None
1022 } else {
1023 Some(theme.legend_text.family.clone())
1024 };
1025 backend.draw_text(
1026 &ov.text,
1027 (x, y),
1028 &TextStyle {
1029 color: theme.legend_text.color,
1030 size: theme.legend_text.size,
1031 anchor: TextAnchor::Start,
1032 angle: 0.0,
1033 family: label_family,
1034 face: theme.legend_text.face,
1035 },
1036 )?;
1037 }
1038
1039 Ok(title_offset + plan.rows as f64 * item_height)
1040}