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 let style = RectStyle {
523 fill: Some(color),
524 stroke: None,
525 stroke_width: 0.0,
526 alpha: 1.0,
527 clip: false,
528 corner_radius: 0.0,
529 };
530 if scale.is_na_break(label) {
533 if let Some(p) = scale.na_fill_pattern() {
534 return backend.draw_rect_pattern(
535 (x, y),
536 (x + swatch_size, y + swatch_size),
537 &p,
538 &style,
539 );
540 }
541 }
542 backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
543 }
544 Aesthetic::Shape => {
545 let shape = scales
546 .map_shape(&value)
547 .unwrap_or(crate::render::backend::PointShape::Circle);
548 backend.draw_shape(
549 (center_x, center_y),
550 swatch_size / 3.0,
551 &PointStyle {
552 color: theme.geom.ink.unwrap_or((50, 50, 50)),
553 alpha: 1.0,
554 filled: true,
555 shape,
556 },
557 )?;
558 }
559 Aesthetic::Linetype => {
560 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
561 backend.draw_line(
562 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
563 &LineStyle {
564 color: theme.geom.ink.unwrap_or((50, 50, 50)),
565 width: 1.5,
566 alpha: 1.0,
567 linetype: lt,
568 ..Default::default()
569 },
570 )?;
571 }
572 Aesthetic::Size => {
573 let size = scale
574 .map_fraction_to_size(pos)
575 .or_else(|| scales.map_size(&value))
576 .unwrap_or(3.0);
577 backend.draw_shape(
578 (center_x, center_y),
579 size.min(swatch_size / 2.0),
580 &PointStyle {
581 color: theme.geom.ink.unwrap_or((50, 50, 50)),
582 alpha: 1.0,
583 filled: true,
584 shape: crate::render::backend::PointShape::Circle,
585 },
586 )?;
587 }
588 Aesthetic::Alpha => {
589 let alpha = scale
590 .map_fraction_to_alpha(pos)
591 .or_else(|| scales.map_alpha(&value))
592 .unwrap_or(1.0);
593 backend.draw_rect(
594 (x, y),
595 (x + swatch_size, y + swatch_size),
596 &RectStyle {
597 fill: Some(theme.geom.ink.unwrap_or((50, 50, 50))),
598 stroke: None,
599 stroke_width: 0.0,
600 alpha,
601 clip: false,
602 corner_radius: 0.0,
603 },
604 )?;
605 }
606 Aesthetic::Linewidth => {
607 let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
608 backend.draw_line(
609 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
610 &LineStyle {
611 color: theme.geom.ink.unwrap_or((50, 50, 50)),
612 width: width.min(swatch_size),
613 alpha: 1.0,
614 linetype: Linetype::Solid,
615 ..Default::default()
616 },
617 )?;
618 }
619 _ => {}
620 }
621 Ok(())
622}
623
624#[derive(Clone, Debug)]
630pub(crate) struct LegendEntry {
631 pub break_idx: usize,
632 pub row: usize,
633 pub x_offset: f64,
634 pub label_override: Option<String>,
641}
642
643#[derive(Clone, Debug)]
647pub(crate) struct LegendOverflow {
648 pub dropped: usize,
649 pub row: usize,
650 pub x_offset: f64,
651 pub text: String,
652}
653
654#[derive(Clone, Debug)]
659pub(crate) struct LegendPlan {
660 pub rows: usize,
661 pub entries: Vec<LegendEntry>,
662 pub overflow: Option<LegendOverflow>,
663 pub cols: usize,
668 pub col_pitch: f64,
671}
672
673fn legend_pitch(label: &str, theme: &Theme) -> f64 {
678 let swatch = theme.legend_key_width;
679 let spacing = theme.legend_spacing;
680 let text_size = theme.legend_text.size;
681 let legacy =
682 swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
683 let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
684 legacy.max(measured)
685}
686
687pub(crate) fn plan_legend(
695 breaks: &[(f64, String)],
696 guide: &GuideLegend,
697 theme: &Theme,
698 avail_width: f64,
699 avail_height: f64,
700 is_horizontal: bool,
701) -> Option<LegendPlan> {
702 let n = breaks.len();
703 if n == 0 {
704 return None;
705 }
706
707 if guide.nrow.is_some() || guide.ncol.is_some() {
709 let (rows, cols) = match (guide.nrow, guide.ncol) {
710 (Some(nrow), Some(ncol)) => (nrow, ncol),
711 (Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
712 (None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
713 (None, None) => unreachable!("checked above"),
714 };
715 let rows = rows.max(1);
716 let cols = cols.max(1);
717 let col_pitch = breaks
718 .iter()
719 .map(|(_, l)| legend_pitch(l, theme))
720 .fold(0.0_f64, f64::max);
721 let entries = (0..n)
722 .map(|i| {
723 let (row, col) = if guide.byrow {
724 (i / cols, i % cols)
725 } else {
726 (i % rows, i / rows)
727 };
728 LegendEntry {
729 break_idx: i,
730 row,
731 x_offset: col as f64 * col_pitch,
732 label_override: None,
733 }
734 })
735 .collect();
736 return Some(LegendPlan {
737 rows,
738 entries,
739 overflow: None,
740 cols,
741 col_pitch,
742 });
743 }
744
745 if !is_horizontal {
747 return None;
748 }
749
750 let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
751 let total_width: f64 = pitches.iter().sum();
752 if total_width <= avail_width.max(0.0) {
753 return None; }
755
756 let item_height = theme.legend_key_height.max(1.0);
757 let row_limit = guide
758 .max_rows
759 .unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
760 .max(1);
761
762 let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
763 let mut row = 0usize;
764 let mut cur_x = 0.0_f64;
765 for (i, &p) in pitches.iter().enumerate() {
766 if cur_x > 0.0 && cur_x + p > avail_width {
767 if row + 1 >= row_limit {
768 break; }
770 row += 1;
771 cur_x = 0.0;
772 }
773 entries.push(LegendEntry {
774 break_idx: i,
775 row,
776 x_offset: cur_x,
777 label_override: None,
778 });
779 cur_x += p;
780 }
781
782 if entries.len() >= n {
783 return Some(LegendPlan {
784 rows: row + 1,
785 entries,
786 overflow: None,
787 cols: 1,
788 col_pitch: 0.0,
789 });
790 }
791
792 let mut dropped = n - entries.len();
795 loop {
796 let text = format!("+{dropped} more");
797 let more_pitch = legend_pitch(&text, theme);
798 let row_count = entries.iter().filter(|e| e.row == row).count();
799 let last_x = entries
800 .iter()
801 .filter(|e| e.row == row)
802 .map(|e| e.x_offset + pitches[e.break_idx])
803 .fold(0.0_f64, f64::max);
804 if last_x + more_pitch <= avail_width || row_count == 0 {
805 return Some(LegendPlan {
806 rows: row + 1,
807 entries,
808 overflow: Some(LegendOverflow {
809 dropped,
810 row,
811 x_offset: last_x,
812 text,
813 }),
814 cols: 1,
815 col_pitch: 0.0,
816 });
817 }
818 entries.pop();
819 dropped += 1;
820 }
821}
822
823fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
827 legend_scales
828 .iter()
829 .filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
830 .count()
831 .max(1)
832}
833
834fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
841 legend_scales
842 .iter()
843 .filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
844 .count()
845}
846
847fn continuous_guide_width(theme: &Theme) -> f64 {
852 theme.legend_key_width + theme.legend_text.size * 6.0 + theme.legend_spacing * 2.0
853}
854
855#[allow(clippy::too_many_arguments)]
869pub(crate) fn plan_all_legends(
870 scales: &ScaleSet,
871 guide: &GuideLegend,
872 theme: &Theme,
873 suppressed: &std::collections::HashSet<Aesthetic>,
874 avail_width: f64,
875 avail_height: f64,
876 is_horizontal: bool,
877 warnings: &mut Vec<String>,
878) -> std::collections::HashMap<Aesthetic, LegendPlan> {
879 let legend_scales = legend_scales_for(scales, suppressed);
880 let n_discrete = discrete_guide_count(scales, &legend_scales);
881 let per_guide_width = if is_horizontal {
882 let n_continuous = continuous_guide_count(scales, &legend_scales);
888 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
889 ((avail_width - continuous_width) / n_discrete as f64).max(0.0)
890 } else {
891 avail_width
892 };
893
894 let mut plans = std::collections::HashMap::new();
895 for aes in &legend_scales {
896 let Some(scale) = scales.get(aes) else {
897 continue;
898 };
899 if !scale.is_discrete() {
900 continue;
901 }
902 let breaks = scale.breaks();
903 let validated_guide = guide.validated(breaks.len(), warnings);
904 if let Some(plan) = plan_legend(
905 &breaks,
906 &validated_guide,
907 theme,
908 per_guide_width,
909 avail_height,
910 is_horizontal,
911 ) {
912 plans.insert(aes.clone(), plan);
913 }
914 }
915 plans
916}
917
918#[allow(clippy::too_many_arguments)]
928pub(crate) fn draw_legend_planned(
929 scales: &ScaleSet,
930 theme: &Theme,
931 plot_area: &Rect,
932 legend_avail_width: f64,
942 backend: &mut dyn DrawBackend,
943 guide: &GuideLegend,
944 suppressed: &std::collections::HashSet<Aesthetic>,
945 legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
951) -> Result<(), RenderError> {
952 if matches!(theme.legend_position, LegendPosition::None) {
953 return Ok(());
954 }
955
956 let legend_scales = legend_scales_for(scales, suppressed);
957 if legend_scales.is_empty() {
958 return Ok(());
959 }
960
961 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
962 if let Some(dir) = theme.legend_direction {
963 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
964 }
965 let n_discrete = discrete_guide_count(scales, &legend_scales);
966 let n_continuous = continuous_guide_count(scales, &legend_scales);
971 let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
972 let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
973
974 let mut offset_y = legend_y;
975 let mut offset_x = legend_x;
976
977 for aes in &legend_scales {
978 let scale = scales.get(aes).unwrap();
979 if !scale.is_discrete() {
980 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
983 let height =
984 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
985 if is_horizontal {
986 offset_x += theme.legend_key_width
987 + theme.legend_text.size * 6.0
988 + theme.legend_spacing * 2.0;
989 } else {
990 offset_y += height + theme.legend_spacing * 2.0;
991 }
992 } else {
993 let height = draw_discrete_legend_at(
994 scales,
995 aes,
996 scale,
997 theme,
998 offset_x,
999 offset_y,
1000 backend,
1001 guide,
1002 is_horizontal,
1003 )?;
1004 if is_horizontal {
1005 offset_x += theme.legend_key_width
1006 + theme.legend_text.size * 6.0
1007 + theme.legend_spacing * 2.0;
1008 } else {
1009 offset_y += height + theme.legend_spacing * 2.0;
1010 }
1011 }
1012 continue;
1013 }
1014
1015 match legend_plans.get(aes) {
1016 None => {
1017 if is_horizontal {
1018 let width = draw_discrete_legend_at(
1019 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
1020 )?;
1021 offset_x += width + theme.legend_spacing * 2.0;
1022 } else {
1023 let height = draw_discrete_legend_at(
1024 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
1025 )?;
1026 offset_y += height + theme.legend_spacing * 2.0;
1027 }
1028 }
1029 Some(plan) => {
1030 let height = draw_discrete_legend_grid(
1031 scales, aes, scale, theme, offset_x, offset_y, backend, guide, plan,
1032 )?;
1033 if is_horizontal {
1034 offset_x += per_guide_width;
1038 } else {
1039 offset_y += height + theme.legend_spacing * 2.0;
1040 }
1041 }
1042 }
1043 }
1044
1045 Ok(())
1046}
1047
1048#[allow(clippy::too_many_arguments)]
1052fn draw_discrete_legend_grid(
1053 scales: &ScaleSet,
1054 aes: &Aesthetic,
1055 scale: &dyn crate::scale::Scale,
1056 theme: &Theme,
1057 legend_x: f64,
1058 legend_y: f64,
1059 backend: &mut dyn DrawBackend,
1060 guide: &GuideLegend,
1061 plan: &LegendPlan,
1062) -> Result<f64, RenderError> {
1063 let mut breaks = scale.breaks();
1064 if breaks.is_empty() {
1065 return Ok(0.0);
1066 }
1067 if guide.reverse {
1068 breaks.reverse();
1069 }
1070
1071 let swatch_size = theme.legend_key_width;
1072 let item_height = theme.legend_key_height;
1073
1074 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
1075 let legend_family = if theme.legend_title.family.is_empty() {
1076 None
1077 } else {
1078 Some(theme.legend_title.family.clone())
1079 };
1080 let title_offset = if !title.is_empty() {
1081 backend.draw_text(
1082 title,
1083 (legend_x, legend_y),
1084 &TextStyle {
1085 color: theme.legend_title.color,
1086 size: theme.legend_title.size,
1087 anchor: TextAnchor::Start,
1088 angle: 0.0,
1089 family: legend_family,
1090 face: theme.legend_title.face,
1091 },
1092 )?;
1093 theme.legend_title.size + 4.0
1094 } else {
1095 0.0
1096 };
1097 let items_y = legend_y + title_offset;
1098
1099 for entry in &plan.entries {
1100 let (pos, label) = &breaks[entry.break_idx];
1101 let x = legend_x + entry.x_offset;
1102 let y = items_y + entry.row as f64 * item_height;
1103 let center_x = x + swatch_size / 2.0;
1104 let center_y = y + swatch_size / 2.0;
1105
1106 if theme.legend_key.visible {
1107 if let Some(fill) = theme.legend_key.fill {
1108 backend.draw_rect(
1109 (x, y),
1110 (x + swatch_size, y + swatch_size),
1111 &RectStyle {
1112 fill: Some(fill),
1113 stroke: theme.legend_key.color,
1114 stroke_width: theme.legend_key.width,
1115 alpha: 1.0,
1116 clip: false,
1117 corner_radius: 0.0,
1118 },
1119 )?;
1120 }
1121 }
1122
1123 draw_legend_swatch(
1124 backend,
1125 scales,
1126 aes,
1127 scale,
1128 theme,
1129 label,
1130 *pos,
1131 x,
1132 y,
1133 center_x,
1134 center_y,
1135 swatch_size,
1136 )?;
1137
1138 let label_family = if theme.legend_text.family.is_empty() {
1139 None
1140 } else {
1141 Some(theme.legend_text.family.clone())
1142 };
1143 let display = entry.label_override.as_deref().unwrap_or(label.as_str());
1147 backend.draw_text_with_title(
1148 display,
1149 (x + swatch_size + theme.legend_spacing, center_y),
1150 &TextStyle {
1151 color: theme.legend_text.color,
1152 size: theme.legend_text.size,
1153 anchor: TextAnchor::Start,
1154 angle: 0.0,
1155 family: label_family,
1156 face: theme.legend_text.face,
1157 },
1158 entry.label_override.as_deref().map(|_| label.as_str()),
1159 )?;
1160 }
1161
1162 if let Some(ov) = &plan.overflow {
1164 let x = legend_x + ov.x_offset;
1165 let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
1166 let label_family = if theme.legend_text.family.is_empty() {
1167 None
1168 } else {
1169 Some(theme.legend_text.family.clone())
1170 };
1171 backend.draw_text(
1172 &ov.text,
1173 (x, y),
1174 &TextStyle {
1175 color: theme.legend_text.color,
1176 size: theme.legend_text.size,
1177 anchor: TextAnchor::Start,
1178 angle: 0.0,
1179 family: label_family,
1180 face: theme.legend_text.face,
1181 },
1182 )?;
1183 }
1184
1185 Ok(title_offset + plan.rows as f64 * item_height)
1186}