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 fn draw_legend(
24 scales: &ScaleSet,
25 theme: &Theme,
26 plot_area: &Rect,
27 backend: &mut dyn DrawBackend,
28 guide: &GuideLegend,
29 suppressed: &std::collections::HashSet<Aesthetic>,
30) -> Result<(), RenderError> {
31 if matches!(theme.legend_position, LegendPosition::None) {
32 return Ok(());
33 }
34
35 let mut legend_scales: Vec<&Aesthetic> = Vec::new();
37 for aes in LEGEND_AESTHETICS {
38 if suppressed.contains(aes) {
40 continue;
41 }
42 if let Some(scale) = scales.get(aes) {
43 if !scale.breaks().is_empty() {
44 if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
46 continue;
47 }
48 legend_scales.push(aes);
49 }
50 }
51 }
52
53 if legend_scales.is_empty() {
54 return Ok(());
55 }
56
57 let (legend_x, legend_y, mut is_horizontal) = legend_position(theme, plot_area);
59 if let Some(dir) = theme.legend_direction {
61 is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
62 }
63
64 let mut offset_y = legend_y;
65 let mut offset_x = legend_x;
66
67 for aes in &legend_scales {
68 let scale = scales.get(aes).unwrap();
69
70 if scale.is_discrete() {
71 if is_horizontal {
72 let width = draw_discrete_legend_at(
73 scales, aes, scale, theme, offset_x, offset_y, backend, guide, true,
74 )?;
75 offset_x += width + theme.legend_spacing * 2.0;
76 } else {
77 let height = draw_discrete_legend_at(
78 scales, aes, scale, theme, offset_x, offset_y, backend, guide, false,
79 )?;
80 offset_y += height + theme.legend_spacing * 2.0;
81 }
82 } else {
83 if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
85 let height =
86 draw_continuous_legend_at(scale, theme, offset_x, offset_y, backend, guide)?;
87 if is_horizontal {
88 offset_x += theme.legend_key_width
89 + theme.legend_text.size * 6.0
90 + theme.legend_spacing * 2.0;
91 } else {
92 offset_y += height + theme.legend_spacing * 2.0;
93 }
94 } else {
95 let height = draw_discrete_legend_at(
97 scales,
98 aes,
99 scale,
100 theme,
101 offset_x,
102 offset_y,
103 backend,
104 guide,
105 is_horizontal,
106 )?;
107 if is_horizontal {
108 offset_x += theme.legend_key_width
109 + theme.legend_text.size * 6.0
110 + theme.legend_spacing * 2.0;
111 } else {
112 offset_y += height + theme.legend_spacing * 2.0;
113 }
114 }
115 }
116 }
117
118 Ok(())
119}
120
121fn legend_position(theme: &Theme, plot_area: &Rect) -> (f64, f64, bool) {
124 match theme.legend_position {
125 LegendPosition::Right => (
126 plot_area.x + plot_area.width + theme.legend_margin.left,
127 plot_area.y + theme.legend_margin.top,
128 false,
129 ),
130 LegendPosition::Left => (
131 theme.legend_margin.left,
132 plot_area.y + theme.legend_margin.top,
133 false,
134 ),
135 LegendPosition::Top => (
136 plot_area.x + theme.legend_margin.left,
137 theme.legend_margin.top,
138 true,
139 ),
140 LegendPosition::Bottom => (
141 plot_area.x + theme.legend_margin.left,
142 plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
143 true,
144 ),
145 LegendPosition::None => (0.0, 0.0, false),
146 LegendPosition::Inside(fx, fy) => (
147 plot_area.x + fx * plot_area.width,
148 plot_area.y + (1.0 - fy) * plot_area.height,
149 false,
150 ),
151 }
152}
153
154#[allow(clippy::too_many_arguments)]
156fn draw_discrete_legend_at(
157 scales: &ScaleSet,
158 aes: &Aesthetic,
159 scale: &dyn crate::scale::Scale,
160 theme: &Theme,
161 legend_x: f64,
162 legend_y: f64,
163 backend: &mut dyn DrawBackend,
164 guide: &GuideLegend,
165 is_horizontal: bool,
166) -> Result<f64, RenderError> {
167 let mut breaks = scale.breaks();
168 if breaks.is_empty() {
169 return Ok(0.0);
170 }
171
172 if guide.reverse {
174 breaks.reverse();
175 }
176
177 let item_height = theme.legend_key_height;
178 let swatch_size = theme.legend_key_width;
179
180 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
182 let legend_family = if theme.legend_title.family.is_empty() {
183 None
184 } else {
185 Some(theme.legend_title.family.clone())
186 };
187 let title_offset = if !title.is_empty() {
188 backend.draw_text(
189 title,
190 (legend_x, legend_y),
191 &TextStyle {
192 color: theme.legend_title.color,
193 size: theme.legend_title.size,
194 anchor: TextAnchor::Start,
195 angle: 0.0,
196 family: legend_family,
197 face: theme.legend_title.face,
198 },
199 )?;
200 theme.legend_title.size + 4.0
201 } else {
202 0.0
203 };
204
205 let items_y = legend_y + title_offset;
206
207 if theme.legend_background.visible {
209 let total_height = breaks.len() as f64 * item_height;
210 let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
211 if let Some(fill) = theme.legend_background.fill {
212 backend.draw_rect(
213 (legend_x - 2.0, items_y - 2.0),
214 (legend_x + total_width + 2.0, items_y + total_height + 2.0),
215 &RectStyle {
216 fill: Some(fill),
217 stroke: theme.legend_background.color,
218 stroke_width: theme.legend_background.width,
219 alpha: 1.0,
220 clip: false,
221 },
222 )?;
223 }
224 }
225
226 let mut cur_x = legend_x;
228 for (i, (pos, label)) in breaks.iter().enumerate() {
229 let x = if is_horizontal { cur_x } else { legend_x };
230 let y = if is_horizontal {
231 items_y
232 } else {
233 items_y + i as f64 * item_height
234 };
235 let center_x = x + swatch_size / 2.0;
236 let center_y = y + swatch_size / 2.0;
237
238 if theme.legend_key.visible {
240 if let Some(fill) = theme.legend_key.fill {
241 backend.draw_rect(
242 (x, y),
243 (x + swatch_size, y + swatch_size),
244 &RectStyle {
245 fill: Some(fill),
246 stroke: theme.legend_key.color,
247 stroke_width: theme.legend_key.width,
248 alpha: 1.0,
249 clip: false,
250 },
251 )?;
252 }
253 }
254
255 let value = Value::Str(label.clone());
257 match aes {
258 Aesthetic::Color | Aesthetic::Fill => {
259 let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
260 backend.draw_rect(
261 (x, y),
262 (x + swatch_size, y + swatch_size),
263 &RectStyle {
264 fill: Some(color),
265 stroke: None,
266 stroke_width: 0.0,
267 alpha: 1.0,
268 clip: false,
269 },
270 )?;
271 }
272 Aesthetic::Shape => {
273 let shape = scales
274 .map_shape(&value)
275 .unwrap_or(crate::render::backend::PointShape::Circle);
276 backend.draw_shape(
277 (center_x, center_y),
278 swatch_size / 3.0,
279 &PointStyle {
280 color: (50, 50, 50),
281 alpha: 1.0,
282 filled: true,
283 shape,
284 },
285 )?;
286 }
287 Aesthetic::Linetype => {
288 let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
289 backend.draw_line(
290 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
291 &LineStyle {
292 color: (50, 50, 50),
293 width: 1.5,
294 alpha: 1.0,
295 linetype: lt,
296 ..Default::default()
297 },
298 )?;
299 }
300 Aesthetic::Size => {
301 let size = scales.map_size(&value).unwrap_or(3.0);
303 backend.draw_shape(
304 (center_x, center_y),
305 size.min(swatch_size / 2.0),
306 &PointStyle {
307 color: (50, 50, 50),
308 alpha: 1.0,
309 filled: true,
310 shape: crate::render::backend::PointShape::Circle,
311 },
312 )?;
313 }
314 Aesthetic::Alpha => {
315 let alpha = scales.map_alpha(&value).unwrap_or(1.0);
316 backend.draw_rect(
317 (x, y),
318 (x + swatch_size, y + swatch_size),
319 &RectStyle {
320 fill: Some((50, 50, 50)),
321 stroke: None,
322 stroke_width: 0.0,
323 alpha,
324 clip: false,
325 },
326 )?;
327 }
328 Aesthetic::Linewidth => {
329 let width = scale.map_fraction_to_size(*pos).unwrap_or(1.0);
334 backend.draw_line(
335 &[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
336 &LineStyle {
337 color: (50, 50, 50),
338 width: width.min(swatch_size),
339 alpha: 1.0,
340 linetype: Linetype::Solid,
341 ..Default::default()
342 },
343 )?;
344 }
345 _ => {}
346 }
347
348 let label_family = if theme.legend_text.family.is_empty() {
350 None
351 } else {
352 Some(theme.legend_text.family.clone())
353 };
354 backend.draw_text(
355 label,
356 (x + swatch_size + theme.legend_spacing, center_y),
357 &TextStyle {
358 color: theme.legend_text.color,
359 size: theme.legend_text.size,
360 anchor: TextAnchor::Start,
361 angle: 0.0,
362 family: label_family,
363 face: theme.legend_text.face,
364 },
365 )?;
366 if is_horizontal {
367 cur_x = x
369 + swatch_size
370 + theme.legend_spacing
371 + (label.chars().count() as f64) * theme.legend_text.size * 0.62
372 + theme.legend_spacing * 3.0;
373 }
374 }
375
376 if is_horizontal {
377 Ok(cur_x - legend_x) } else {
379 Ok(title_offset + breaks.len() as f64 * item_height)
380 }
381}
382
383fn draw_continuous_legend_at(
385 scale: &dyn crate::scale::Scale,
386 theme: &Theme,
387 legend_x: f64,
388 legend_y: f64,
389 backend: &mut dyn DrawBackend,
390 guide: &GuideLegend,
391) -> Result<f64, RenderError> {
392 let breaks = scale.breaks();
393 if breaks.is_empty() {
394 return Ok(0.0);
395 }
396
397 let bar_width = theme.legend_key_width;
398 let bar_height = theme.legend_key_height * 8.0;
399
400 let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
402 let cont_family = if theme.legend_title.family.is_empty() {
403 None
404 } else {
405 Some(theme.legend_title.family.clone())
406 };
407 let title_offset = if !title.is_empty() {
408 backend.draw_text(
409 title,
410 (legend_x, legend_y),
411 &TextStyle {
412 color: theme.legend_title.color,
413 size: theme.legend_title.size,
414 anchor: TextAnchor::Start,
415 angle: 0.0,
416 family: cont_family,
417 face: theme.legend_title.face,
418 },
419 )?;
420 theme.legend_title.size + 4.0
421 } else {
422 0.0
423 };
424
425 let bar_top = legend_y + title_offset;
426
427 if theme.legend_background.visible {
429 let total_width = bar_width + theme.legend_spacing + theme.legend_text.size * 6.0;
430 if let Some(fill) = theme.legend_background.fill {
431 backend.draw_rect(
432 (legend_x - 2.0, bar_top - 2.0),
433 (legend_x + total_width + 2.0, bar_top + bar_height + 2.0),
434 &RectStyle {
435 fill: Some(fill),
436 stroke: theme.legend_background.color,
437 stroke_width: theme.legend_background.width,
438 alpha: 1.0,
439 clip: false,
440 },
441 )?;
442 }
443 }
444
445 let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
448 let n_slices = 50;
449 let slice_height = bar_height / n_slices as f64;
450 for i in 0..n_slices {
451 let t = 1.0 - i as f64 / n_slices as f64;
452 let data_val = data_min + t * (data_max - data_min);
453 let color = scale
454 .map_to_color(&Value::Float(data_val))
455 .unwrap_or((127, 127, 127));
456 let sy = bar_top + i as f64 * slice_height;
457 backend.draw_rect(
458 (legend_x, sy),
459 (legend_x + bar_width, sy + slice_height + 0.5),
460 &RectStyle {
461 fill: Some(color),
462 stroke: None,
463 stroke_width: 0.0,
464 alpha: 1.0,
465 clip: false,
466 },
467 )?;
468 }
469
470 let border = theme.legend_bar_border_line();
473 let border_style = LineStyle {
474 color: border.color,
475 width: border.width,
476 alpha: 1.0,
477 linetype: border.linetype,
478 ..Default::default()
479 };
480 if border.visible {
481 backend.draw_line(
482 &[
483 (legend_x, bar_top),
484 (legend_x + bar_width, bar_top),
485 (legend_x + bar_width, bar_top + bar_height),
486 (legend_x, bar_top + bar_height),
487 (legend_x, bar_top),
488 ],
489 &border_style,
490 )?;
491 }
492
493 let tick_len = 3.0;
495 for (pos, label) in &breaks {
496 let tick_y = bar_top + bar_height * (1.0 - pos);
497 backend.draw_line(
498 &[
499 (legend_x + bar_width, tick_y),
500 (legend_x + bar_width + tick_len, tick_y),
501 ],
502 &border_style,
503 )?;
504 let tick_family = if theme.legend_text.family.is_empty() {
505 None
506 } else {
507 Some(theme.legend_text.family.clone())
508 };
509 backend.draw_text(
510 label,
511 (
512 legend_x + bar_width + tick_len + theme.legend_spacing,
513 tick_y,
514 ),
515 &TextStyle {
516 color: theme.legend_text.color,
517 size: theme.legend_text.size,
518 anchor: TextAnchor::Start,
519 angle: 0.0,
520 family: tick_family,
521 face: theme.legend_text.face,
522 },
523 )?;
524 }
525
526 Ok(title_offset + bar_height)
527}