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