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 bg_colour: None,
241 bg_r: 0.0,
242 },
243 )?;
244 theme.legend_title.size + 4.0
245 } else {
246 0.0
247 };
248
249 let items_y = legend_y + title_offset;
250
251 if theme.legend_background.visible {
253 let total_height = breaks.len() as f64 * item_height;
254 let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
255 if let Some(fill) = theme.legend_background.fill {
256 backend.draw_rect(
257 (legend_x - 2.0, items_y - 2.0),
258 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
259 &RectStyle {
260 fill: Some(fill),
261 stroke: theme.legend_background.color,
262 stroke_width: theme.legend_background.width,
263 alpha: 1.0,
264 clip: false,
265 corner_radius: 0.0,
266 },
267 )?;
268 }
269 }
270
271 let mut cur_x = legend_x;
273 for (i, (pos, label)) in breaks.iter().enumerate() {
274 let x = if is_horizontal { cur_x } else { legend_x };
275 let y = if is_horizontal {
276 items_y
277 } else {
278 items_y + i as f64 * item_height
279 };
280 let center_x = x + swatch_size / 2.0;
281 let center_y = y + swatch_size / 2.0;
282
283 if theme.legend_key.visible {
285 if let Some(fill) = theme.legend_key.fill {
286 backend.draw_rect(
287 (x, y),
288 (x + swatch_size, y + swatch_size),
289 &RectStyle {
290 fill: Some(fill),
291 stroke: theme.legend_key.color,
292 stroke_width: theme.legend_key.width,
293 alpha: 1.0,
294 clip: false,
295 corner_radius: 0.0,
296 },
297 )?;
298 }
299 }
300
301 draw_legend_swatch(
303 backend,
304 scales,
305 aes,
306 scale,
307 theme,
308 label,
309 *pos,
310 x,
311 y,
312 center_x,
313 center_y,
314 swatch_size,
315 )?;
316
317 let label_family = if theme.legend_text.family.is_empty() {
319 None
320 } else {
321 Some(theme.legend_text.family.clone())
322 };
323 backend.draw_text(
324 label,
325 (x + swatch_size + theme.legend_spacing, center_y),
326 &TextStyle {
327 color: theme.legend_text.color,
328 size: theme.legend_text.size,
329 anchor: TextAnchor::Start,
330 angle: 0.0,
331 family: label_family,
332 face: theme.legend_text.face,
333 bg_colour: None,
334 bg_r: 0.0,
335 },
336 )?;
337 if is_horizontal {
338 cur_x = x
340 + swatch_size
341 + theme.legend_spacing
342 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
343 + theme.legend_spacing * 3.0;
344 }
345 }
346
347 if is_horizontal {
348 Ok(cur_x - legend_x) } else {
350 Ok(title_offset + breaks.len() as f64 * item_height)
351 }
352}
353
354fn draw_continuous_legend_at(
356 scale: &dyn crate::scale::Scale,
357 theme: &Theme,
358 legend_x: f64,
359 legend_y: f64,
360 backend: &mut dyn DrawBackend,
361 guide: &GuideLegend,
362) -> Result<f64, RenderError> {
363 let breaks = scale.breaks();
364 if breaks.is_empty() {
365 return Ok(0.0);
366 }
367
368 let bar_width = theme.legend_key_width;
369 let bar_height = theme.legend_key_height * 8.0;
370
371 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
373 let cont_family = if theme.legend_title.family.is_empty() {
374 None
375 } else {
376 Some(theme.legend_title.family.clone())
377 };
378 let title_offset = if !title.is_empty() {
379 backend.draw_text(
380 title,
381 (legend_x, legend_y),
382 &TextStyle {
383 color: theme.legend_title.color,
384 size: theme.legend_title.size,
385 anchor: TextAnchor::Start,
386 angle: 0.0,
387 family: cont_family,
388 face: theme.legend_title.face,
389 bg_colour: None,
390 bg_r: 0.0,
391 },
392 )?;
393 theme.legend_title.size + 4.0
394 } else {
395 0.0
396 };
397
398 let bar_top = legend_y + title_offset;
399
400 if theme.legend_background.visible {
402 let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
403 if let Some(fill) = theme.legend_background.fill {
404 backend.draw_rect(
405 (legend_x - 2.0, bar_top - 2.0),
406 (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
407 &RectStyle {
408 fill: Some(fill),
409 stroke: theme.legend_background.color,
410 stroke_width: theme.legend_background.width,
411 alpha: 1.0,
412 clip: false,
413 corner_radius: 0.0,
414 },
415 )?;
416 }
417 }
418
419 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
422 let n_slices = 50;
423 let slice_height = bar_height / n_slices as f64;
424 for i in 0..n_slices {
425 let t = 1.0 - i as f64 / n_slices as f64;
426 let data_val = data_min + t * (data_max - data_min);
427 let color = scale
428 .map_to_color(&Value::Float(data_val))
429 .unwrap_or((127, 127, 127));
430 let sy = bar_top + i as f64 * slice_height;
431 backend.draw_rect(
432 (legend_x, sy),
433 (legend_x + bar_width, sy + slice_height + 0.5),
434 &RectStyle {
435 fill: Some(color),
436 stroke: None,
437 stroke_width: 0.0,
438 alpha: 1.0,
439 clip: false,
440 corner_radius: 0.0,
441 },
442 )?;
443 }
444
445 let border = theme.legend_bar_border_line();
448 let border_style = LineStyle {
449 color: border.color,
450 width: border.width,
451 alpha: 1.0,
452 linetype: border.linetype,
453 ..Default::default()
454 };
455 if border.visible {
456 backend.draw_line(
457 &[
458 (legend_x, bar_top),
459 (legend_x + bar_width, bar_top),
460 (legend_x + bar_width, bar_top + bar_height),
461 (legend_x, bar_top + bar_height),
462 (legend_x, bar_top),
463 ],
464 &border_style,
465 )?;
466 }
467
468 let tick_len = 3.0;
470 for (pos, label) in &breaks {
471 let tick_y = bar_top + bar_height * (1.0 - pos);
472 backend.draw_line(
473 &[
474 (legend_x + bar_width, tick_y),
475 (legend_x + bar_width + tick_len, tick_y),
476 ],
477 &border_style,
478 )?;
479 let tick_family = if theme.legend_text.family.is_empty() {
480 None
481 } else {
482 Some(theme.legend_text.family.clone())
483 };
484 backend.draw_text(
485 label,
486 (
487 legend_x + bar_width + tick_len + theme.legend_spacing,
488 tick_y,
489 ),
490 &TextStyle {
491 color: theme.legend_text.color,
492 size: theme.legend_text.size,
493 anchor: TextAnchor::Start,
494 angle: 0.0,
495 family: tick_family,
496 face: theme.legend_text.face,
497 bg_colour: None,
498 bg_r: 0.0,
499 },
500 )?;
501 }
502
503 Ok(title_offset + bar_height)
504}
505
506#[allow(clippy::too_many_arguments)]
512fn draw_legend_swatch(
513 backend: &mut dyn DrawBackend,
514 scales: &ScaleSet,
515 aes: &Aesthetic,
516 scale: &dyn crate::scale::Scale,
517 theme: &Theme,
518 label: &str,
519 pos: f64,
520 x: f64,
521 y: f64,
522 center_x: f64,
523 center_y: f64,
524 swatch_size: f64,
525) -> Result<(), RenderError> {
526 let value = Value::Str(label.to_string());
527 match aes {
528 Aesthetic::Color | Aesthetic::Fill => {
529 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
530 let style = RectStyle {
531 fill: Some(color),
532 stroke: None,
533 stroke_width: 0.0,
534 alpha: 1.0,
535 clip: false,
536 corner_radius: 0.0,
537 };
538 if scale.is_na_break(label) {
541 if let Some(p) = scale.na_fill_pattern() {
542 return backend.draw_rect_pattern(
543 (x, y),
544 (x + swatch_size, y + swatch_size),
545 &p,
546 &style,
547 );
548 }
549 }
550 backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
551 }
552 Aesthetic::Shape => {
553 let shape = scales
554 .map_shape(&value)
555 .unwrap_or(crate::render::backend::PointShape::Circle);
556 backend.draw_shape(
557 (center_x, center_y),
558 swatch_size / 3.0,
559 &PointStyle {
560 color: theme.geom.ink.unwrap_or((50, 50, 50)),
561 alpha: 1.0,
562 filled: true,
563 shape,
564 },
565 )?;
566 }
567 Aesthetic::Linetype => {
568 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
569 backend.draw_line(
570 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
571 &LineStyle {
572 color: theme.geom.ink.unwrap_or((50, 50, 50)),
573 width: 1.5,
574 alpha: 1.0,
575 linetype: lt,
576 ..Default::default()
577 },
578 )?;
579 }
580 Aesthetic::Size => {
581 let size = scale
582 .map_fraction_to_size(pos)
583 .or_else(|| scales.map_size(&value))
584 .unwrap_or(3.0);
585 backend.draw_shape(
586 (center_x, center_y),
587 size.min(swatch_size / 2.0),
588 &PointStyle {
589 color: theme.geom.ink.unwrap_or((50, 50, 50)),
590 alpha: 1.0,
591 filled: true,
592 shape: crate::render::backend::PointShape::Circle,
593 },
594 )?;
595 }
596 Aesthetic::Alpha => {
597 let alpha = scale
598 .map_fraction_to_alpha(pos)
599 .or_else(|| scales.map_alpha(&value))
600 .unwrap_or(1.0);
601 backend.draw_rect(
602 (x, y),
603 (x + swatch_size, y + swatch_size),
604 &RectStyle {
605 fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
606 stroke: None,
607 stroke_width: 0.0,
608 alpha,
609 clip: false,
610 corner_radius: 0.0,
611 },
612 )?;
613 }
614 Aesthetic::Linewidth => {
615 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
616 backend.draw_line(
617 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
618 &LineStyle {
619 color: theme.geom.ink.unwrap_or((50, 50, 50)),
620 width: width.min(swatch_size),
621 alpha: 1.0,
622 linetype: Linetype::Solid,
623 ..Default::default()
624 },
625 )?;
626 }
627 _ => {}
628 }
629 Ok(())
630}
631
632#[derive(Clone, Debug)]
638pub(crate) struct LegendEntry {
639 pub break_idx: usize,
640 pub row: usize,
641 pub x_offset: f64,
642 pub label_override: Option<String>,
649}
650
651#[derive(Clone, Debug)]
655pub(crate) struct LegendOverflow {
656 pub dropped: usize,
657 pub row: usize,
658 pub x_offset: f64,
659 pub text: String,
660}
661
662#[derive(Clone, Debug)]
667pub(crate) struct LegendPlan {
668 pub rows: usize,
669 pub entries: Vec<LegendEntry>,
670 pub overflow: Option<LegendOverflow>,
671 pub cols: usize,
676 pub col_pitch: f64,
679}
680
681fn legend_pitch(label: &str, theme: &Theme) -> f64 {
686 let swatch = theme.legend_key_width;
687 let spacing = theme.legend_spacing;
688 let text_size = theme.legend_text.size;
689 let legacy =
690 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
691 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
692 legacy.max(measured)
693}
694
695pub(crate) fn plan_legend(
703 breaks: &[(f64, String)],
704 guide: &GuideLegend,
705 theme: &Theme,
706 avail_width: f64,
707 avail_height: f64,
708 is_horizontal: bool,
709) -> Option<LegendPlan> {
710 let n = breaks.len();
711 if n == 0 {
712 return None;
713 }
714
715 if guide.nrow.is_some() || guide.ncol.is_some() {
717 let (rows, cols) = match (guide.nrow, guide.ncol) {
718 (Some(nrow), Some(ncol)) => (nrow, ncol),
719 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
720 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
721 (None, None) => unreachable!("checked above"),
722 };
723 let rows = rows.max(1);
724 let cols = cols.max(1);
725 let col_pitch = breaks
726 .iter()
727 .map(|(_, l)| legend_pitch(l, theme))
728 .fold(0.0_f64, f64::max);
729 let entries = (0..n)
730 .map(|i| {
731 let (row, col) = if guide.byrow {
732 (i / cols, i % cols)
733 } else {
734 (i % rows, i / rows)
735 };
736 LegendEntry {
737 break_idx: i,
738 row,
739 x_offset: col as f64 * col_pitch,
740 label_override: None,
741 }
742 })
743 .collect();
744 return Some(LegendPlan {
745 rows,
746 entries,
747 overflow: None,
748 cols,
749 col_pitch,
750 });
751 }
752
753 if !is_horizontal {
755 return None;
756 }
757
758 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
759 let total_width: f64 = pitches.iter().sum();
760 if total_width <= avail_width.max(0.0) {
761 return None; }
763
764 let item_height = theme.legend_key_height.max(1.0);
765 let row_limit = guide
766 .max_rows
767 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
768 .max(1);
769
770 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
771 let mut row = 0usize;
772 let mut cur_x = 0.0_f64;
773 for (i, &p) in pitches.iter().enumerate() {
774 if cur_x > 0.0 && cur_x + p > avail_width {
775 if row + 1 >= row_limit {
776 break; }
778 row += 1;
779 cur_x = 0.0;
780 }
781 entries.push(LegendEntry {
782 break_idx: i,
783 row,
784 x_offset: cur_x,
785 label_override: None,
786 });
787 cur_x += p;
788 }
789
790 if entries.len() >= n {
791 return Some(LegendPlan {
792 rows: row + 1,
793 entries,
794 overflow: None,
795 cols: 1,
796 col_pitch: 0.0,
797 });
798 }
799
800 let mut dropped = n - entries.len();
803 loop {
804 let text = format!("+{dropped} more");
805 let more_pitch = legend_pitch(&text, theme);
806 let row_count = entries.iter().filter(|e| e.row == row).count();
807 let last_x = entries
808 .iter()
809 .filter(|e| e.row == row)
810 .map(|e| e.x_offset + pitches[e.break_idx])
811 .fold(0.0_f64, f64::max);
812 if last_x + more_pitch <= avail_width || row_count == 0 {
813 return Some(LegendPlan {
814 rows: row + 1,
815 entries,
816 overflow: Some(LegendOverflow {
817 dropped,
818 row,
819 x_offset: last_x,
820 text,
821 }),
822 cols: 1,
823 col_pitch: 0.0,
824 });
825 }
826 entries.pop();
827 dropped += 1;
828 }
829}
830
831fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
835 legend_scales
836 .iter()
837 .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
838 .count()
839 .max(1)
840}
841
842fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
849 legend_scales
850 .iter()
851 .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
852 .count()
853}
854
855fn continuous_guide_width(theme: &Theme) -> f64 {
860 theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
861}
862
863#[allow(clippy::too_many_arguments)]
877pub(crate) fn plan_all_legends(
878 scales: &ScaleSet,
879 guide: &GuideLegend,
880 theme: &Theme,
881 suppressed: &std::collections::HashSet<Aesthetic>,
882 avail_width: f64,
883 avail_height: f64,
884 is_horizontal: bool,
885 warnings: &mut Vec<String>,
886) -> std::collections::HashMap<Aesthetic, LegendPlan> {
887 let legend_scales = legend_scales_for(scales, suppressed);
888 let n_discrete = discrete_guide_count(scales, &legend_scales);
889 let per_guide_width = if is_horizontal {
890 let n_continuous = continuous_guide_count(scales, &legend_scales);
896 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
897 ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
898 } else {
899 avail_width
900 };
901
902 let mut plans = std::collections::HashMap::new();
903 for aes in &legend_scales {
904 let Some(scale) = scales.get(aes) else {
905 continue;
906 };
907 if !scale.is_discrete() {
908 continue;
909 }
910 let breaks = scale.breaks();
911 let validated_guide = guide.validated(breaks.len(), warnings);
912 if let Some(plan) = plan_legend(
913 &breaks,
914 &validated_guide,
915 theme,
916 per_guide_width,
917 avail_height,
918 is_horizontal,
919 ) {
920 plans.insert(aes.clone(), plan);
921 }
922 }
923 plans
924}
925
926#[allow(clippy::too_many_arguments)]
936pub(crate) fn draw_legend_planned(
937 scales: &ScaleSet,
938 theme: &Theme,
939 plot_area: &Rect,
940 legend_avail_width: f64,
950 backend: &mut dyn DrawBackend,
951 guide: &GuideLegend,
952 suppressed: &std::collections::HashSet<Aesthetic>,
953 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
959) -> Result<(), RenderError> {
960 if matches!(theme.legend_position, LegendPosition::None) {
961 return Ok(());
962 }
963
964 let legend_scales = legend_scales_for(scales, suppressed);
965 if legend_scales.is_empty() {
966 return Ok(());
967 }
968
969 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
970 if let Some(dir) = theme.legend_direction {
971 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
972 }
973 let n_discrete = discrete_guide_count(scales, &legend_scales);
974 let n_continuous = continuous_guide_count(scales, &legend_scales);
979 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
980 let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
981
982 let mut offset_y = legend_y;
983 let mut offset_x = legend_x;
984
985 for aes in &legend_scales {
986 let scale = scales.get(aes).unwrap();
987 if !scale.is_discrete() {
988 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
991 let height =
992 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
993 if is_horizontal {
994 offset_x += theme.legend_key_width
995 + theme.legend_text.size * 6.0
996 + theme.legend_spacing * 2.0;
997 } else {
998 offset_y += height + theme.legend_spacing * 2.0;
999 }
1000 } else {
1001 let height = draw_discrete_legend_at(
1002 scales,
1003 aes,
1004 scale,
1005 theme,
1006 offset_x,
1007 offset_y,
1008 backend,
1009 guide,
1010 is_horizontal,
1011 )?;
1012 if is_horizontal {
1013 offset_x += theme.legend_key_width
1014 + theme.legend_text.size * 6.0
1015 + theme.legend_spacing * 2.0;
1016 } else {
1017 offset_y += height + theme.legend_spacing * 2.0;
1018 }
1019 }
1020 continue;
1021 }
1022
1023 match legend_plans.get(aes) {
1024 None => {
1025 if is_horizontal {
1026 let width = draw_discrete_legend_at(
1027 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1028 )?;
1029 offset_x += width + theme.legend_spacing * 2.0;
1030 } else {
1031 let height = draw_discrete_legend_at(
1032 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1033 )?;
1034 offset_y += height + theme.legend_spacing * 2.0;
1035 }
1036 }
1037 Some(plan) => {
1038 let height = draw_discrete_legend_grid(
1039 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1040 )?;
1041 if is_horizontal {
1042 offset_x += per_guide_width;
1046 } else {
1047 offset_y += height + theme.legend_spacing * 2.0;
1048 }
1049 }
1050 }
1051 }
1052
1053 Ok(())
1054}
1055
1056#[allow(clippy::too_many_arguments)]
1060fn draw_discrete_legend_grid(
1061 scales: &ScaleSet,
1062 aes: &Aesthetic,
1063 scale: &dyn crate::scale::Scale,
1064 theme: &Theme,
1065 legend_x: f64,
1066 legend_y: f64,
1067 backend: &mut dyn DrawBackend,
1068 guide: &GuideLegend,
1069 plan: &LegendPlan,
1070) -> Result<f64, RenderError> {
1071 let mut breaks = scale.breaks();
1072 if breaks.is_empty() {
1073 return Ok(0.0);
1074 }
1075 if guide.reverse {
1076 breaks.reverse();
1077 }
1078
1079 let swatch_size = theme.legend_key_width;
1080 let item_height = theme.legend_key_height;
1081
1082 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1083 let legend_family = if theme.legend_title.family.is_empty() {
1084 None
1085 } else {
1086 Some(theme.legend_title.family.clone())
1087 };
1088 let title_offset = if !title.is_empty() {
1089 backend.draw_text(
1090 title,
1091 (legend_x, legend_y),
1092 &TextStyle {
1093 color: theme.legend_title.color,
1094 size: theme.legend_title.size,
1095 anchor: TextAnchor::Start,
1096 angle: 0.0,
1097 family: legend_family,
1098 face: theme.legend_title.face,
1099 bg_colour: None,
1100 bg_r: 0.0,
1101 },
1102 )?;
1103 theme.legend_title.size + 4.0
1104 } else {
1105 0.0
1106 };
1107 let items_y = legend_y + title_offset;
1108
1109 for entry in &plan.entries {
1110 let (pos, label) = &breaks[entry.break_idx];
1111 let x = legend_x + entry.x_offset;
1112 let y = items_y + entry.row as f64 * item_height;
1113 let center_x = x + swatch_size / 2.0;
1114 let center_y = y + swatch_size / 2.0;
1115
1116 if theme.legend_key.visible {
1117 if let Some(fill) = theme.legend_key.fill {
1118 backend.draw_rect(
1119 (x, y),
1120 (x + swatch_size, y + swatch_size),
1121 &RectStyle {
1122 fill: Some(fill),
1123 stroke: theme.legend_key.color,
1124 stroke_width: theme.legend_key.width,
1125 alpha: 1.0,
1126 clip: false,
1127 corner_radius: 0.0,
1128 },
1129 )?;
1130 }
1131 }
1132
1133 draw_legend_swatch(
1134 backend,
1135 scales,
1136 aes,
1137 scale,
1138 theme,
1139 label,
1140 *pos,
1141 x,
1142 y,
1143 center_x,
1144 center_y,
1145 swatch_size,
1146 )?;
1147
1148 let label_family = if theme.legend_text.family.is_empty() {
1149 None
1150 } else {
1151 Some(theme.legend_text.family.clone())
1152 };
1153 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1157 backend.draw_text_with_title(
1158 display,
1159 (x + swatch_size + theme.legend_spacing, center_y),
1160 &TextStyle {
1161 color: theme.legend_text.color,
1162 size: theme.legend_text.size,
1163 anchor: TextAnchor::Start,
1164 angle: 0.0,
1165 family: label_family,
1166 face: theme.legend_text.face,
1167 bg_colour: None,
1168 bg_r: 0.0,
1169 },
1170 entry.label_override.as_deref().map(|_| label.as_str()),
1171 )?;
1172 }
1173
1174 if let Some(ov) = &plan.overflow {
1176 let x = legend_x + ov.x_offset;
1177 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1178 let label_family = if theme.legend_text.family.is_empty() {
1179 None
1180 } else {
1181 Some(theme.legend_text.family.clone())
1182 };
1183 backend.draw_text(
1184 &ov.text,
1185 (x, y),
1186 &TextStyle {
1187 color: theme.legend_text.color,
1188 size: theme.legend_text.size,
1189 anchor: TextAnchor::Start,
1190 angle: 0.0,
1191 family: label_family,
1192 face: theme.legend_text.face,
1193 bg_colour: None,
1194 bg_r: 0.0,
1195 },
1196 )?;
1197 }
1198
1199 Ok(title_offset + plan.rows as f64 * item_height)
1200}