1use crate::color::Color;
24use crate::data_config::{DataRenderType, ScatterShape, SeriesConfig};
25use crate::line::LineStylePreset;
26use crate::text::{RichSegment, RichText, rich_segments_from_text};
27use std::ops::Range;
28
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum LegendCorner {
32 TopLeft,
33 TopRight,
34 BottomLeft,
35 BottomRight,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub enum LegendEntryKind {
42 Line,
44 Scatter,
46 LineScatter,
48}
49
50pub fn scatter_shape_char(shape: &ScatterShape) -> char {
54 match shape {
55 ScatterShape::Circle => '○',
56 ScatterShape::CircleFilled => '●',
57 ScatterShape::Square => '□',
58 ScatterShape::SquareFilled => '■',
59 ScatterShape::Triangle => '▲',
60 ScatterShape::TriangleFilled => '▲',
61 ScatterShape::TriangleDown => '▼',
62 ScatterShape::TriangleDownFilled => '▼',
63 ScatterShape::TriangleLeft => '◄',
64 ScatterShape::TriangleLeftFilled => '◄',
65 ScatterShape::TriangleRight => '►',
66 ScatterShape::TriangleRightFilled => '►',
67 ScatterShape::Diamond => '◊',
68 ScatterShape::DiamondFilled => '♦',
69 ScatterShape::Cross => '×',
70 ScatterShape::CrossFilled => '×',
71 ScatterShape::Plus => '+',
72 ScatterShape::PlusFilled => '+',
73 ScatterShape::Pentagon => 'p',
74 ScatterShape::PentagonFilled => 'p',
75 ScatterShape::Hexagon => 'h',
76 ScatterShape::HexagonFilled => 'h',
77 ScatterShape::Octagon => '8',
78 ScatterShape::OctagonFilled => '8',
79 ScatterShape::Star => '*',
80 ScatterShape::StarFilled => '*',
81 }
82}
83
84pub const SYMBOL_FIELD_EM: f32 = 2.0;
88const LEGEND_DASH_NOMINAL_FONT_PX: f32 = 14.0;
89
90fn rule_dash_em(style: LineStylePreset) -> Option<Vec<f32>> {
91 let pattern = style.pattern();
92 if pattern.is_empty() {
93 None
94 } else {
95 Some(
96 pattern
97 .iter()
98 .map(|v| v / LEGEND_DASH_NOMINAL_FONT_PX)
99 .collect(),
100 )
101 }
102}
103
104#[cfg(test)]
105mod legend_update_tests {
106 use super::*;
107 use crate::data_config::DataLineStyleConfig;
108 use crate::line::LineStylePreset;
109
110 fn line_cfg(color: Color, line_style: LineStylePreset) -> SeriesConfig {
111 SeriesConfig {
112 series_id: "s".into(),
113 source_id: None,
114 label: None,
115 x_column: "x".into(),
116 y_column: "y".into(),
117 render_type: DataRenderType::Line {
118 line: DataLineStyleConfig {
119 line_style,
120 line_color: color,
121 line_width: 1.0,
122 },
123 },
124 }
125 }
126
127 fn empty_content() -> RichText {
128 RichText {
129 segments: Vec::new(),
130 color: Color::BLACK,
131 font_size: 14.0,
132 font: String::new(),
133 }
134 }
135
136 #[test]
137 fn dashed_series_symbol_carries_rule_dash() {
138 let red = Color::new(1.0, 0.0, 0.0, 1.0);
139 let segs = series_symbol_segments(&line_cfg(red, LineStylePreset::Dash));
140 assert_eq!(segs.len(), 1);
141 assert!(segs[0].rule);
142 assert_eq!(segs[0].color, Some(red));
143 assert_eq!(
144 segs[0].rule_dash.as_deref(),
145 Some(&[8.0 / 14.0, 4.0 / 14.0][..])
146 );
147 }
148
149 #[test]
150 fn symbol_update_preserves_user_text_and_label_newlines() {
151 let red = Color::new(1.0, 0.0, 0.0, 1.0);
152 let blue = Color::new(0.0, 0.0, 1.0, 1.0);
153 let green = Color::new(0.0, 0.8, 0.0, 1.0);
154 let mut content = empty_content();
155 append_legend_entry(
156 &mut content,
157 series_symbol_segments(&line_cfg(red, LineStylePreset::Solid)),
158 "line 1\nline 2",
159 );
160 append_legend_entry(
161 &mut content,
162 series_symbol_segments(&line_cfg(blue, LineStylePreset::Solid)),
163 "beta",
164 );
165
166 let series = [
167 line_cfg(green, LineStylePreset::Dash),
168 line_cfg(red, LineStylePreset::Dot),
169 ];
170 assert_eq!(
171 update_legend_symbols_preserving_text(&mut content, &series),
172 2
173 );
174 let text: String = content.segments.iter().map(|s| s.text).collect();
175
176 assert_eq!(legend_entry_count(&content), 2);
177 assert!(text.contains("line 1\nline 2"));
178 assert!(text.contains("beta"));
179 assert_eq!(content.segments[0].color, Some(green));
180 assert!(content.segments[0].rule_dash.is_some());
181 }
182
183 #[test]
184 fn label_update_and_remove_touch_only_target_entry() {
185 let mut content = empty_content();
186 append_legend_entry(
187 &mut content,
188 series_symbol_segments(&line_cfg(Color::BLACK, LineStylePreset::Solid)),
189 "alpha",
190 );
191 append_legend_entry(
192 &mut content,
193 series_symbol_segments(&line_cfg(Color::BLACK, LineStylePreset::Solid)),
194 "beta",
195 );
196
197 set_legend_entry_label(
198 &mut content,
199 1,
200 series_symbol_segments(&line_cfg(Color::BLACK, LineStylePreset::Solid)),
201 rich_segments_from_text("gamma"),
202 );
203 let text: String = content.segments.iter().map(|s| s.text).collect();
204 assert!(text.contains("alpha"));
205 assert!(text.contains("gamma"));
206 assert!(!text.contains("beta"));
207
208 assert!(remove_legend_entry(&mut content, 0));
209 let text: String = content.segments.iter().map(|s| s.text).collect();
210 assert!(!text.contains("alpha"));
211 assert!(text.contains("gamma"));
212 assert_eq!(legend_entry_count(&content), 1);
213 }
214
215 #[test]
216 fn custom_legend_without_auto_entry_shape_is_left_untouched() {
217 let mut content = RichText::plain("custom legend text", Color::BLACK, 14.0, "");
218 let before = content.clone();
219 assert_eq!(
220 update_legend_symbols_preserving_text(
221 &mut content,
222 &[line_cfg(Color::BLACK, LineStylePreset::Solid)]
223 ),
224 0
225 );
226 assert_eq!(content, before);
227 assert!(!remove_legend_entry(&mut content, 0));
228 }
229}
230
231fn line_symbol(color: Color, style: LineStylePreset) -> Vec<RichSegment> {
234 let seg = match rule_dash_em(style) {
235 Some(dash) => RichSegment::dashed_rule(SYMBOL_FIELD_EM, Some(color), dash),
236 None => RichSegment::rule(SYMBOL_FIELD_EM, Some(color)),
237 };
238 vec![seg]
239}
240
241fn scatter_symbol(shape: &ScatterShape, color: Color) -> Vec<RichSegment> {
243 vec![RichSegment::fielded(
244 scatter_shape_char(shape),
245 SYMBOL_FIELD_EM,
246 Some(color),
247 )]
248}
249
250fn line_scatter_symbol(shape: &ScatterShape, color: Color) -> Vec<RichSegment> {
254 line_scatter_symbol_with_style(shape, color, LineStylePreset::Solid)
255}
256
257fn line_scatter_symbol_with_style(
258 shape: &ScatterShape,
259 color: Color,
260 style: LineStylePreset,
261) -> Vec<RichSegment> {
262 let glyph_em = 0.7;
263 let rule_em = (SYMBOL_FIELD_EM - glyph_em) * 0.5;
264 let rule = |width_em| match rule_dash_em(style) {
265 Some(dash) => RichSegment::dashed_rule(width_em, Some(color), dash),
266 None => RichSegment::rule(width_em, Some(color)),
267 };
268 vec![
269 rule(rule_em),
270 RichSegment::fielded(scatter_shape_char(shape), glyph_em, Some(color)),
271 rule(rule_em),
272 ]
273}
274
275pub fn symbol_segments(kind: &LegendEntryKind, color: Color) -> Vec<RichSegment> {
279 match kind {
280 LegendEntryKind::Line => line_symbol(color, LineStylePreset::Solid),
281 LegendEntryKind::Scatter => scatter_symbol(&ScatterShape::CircleFilled, color),
282 LegendEntryKind::LineScatter => line_scatter_symbol(&ScatterShape::CircleFilled, color),
283 }
284}
285
286fn blank_symbol() -> Vec<RichSegment> {
296 vec![RichSegment::fielded(' ', SYMBOL_FIELD_EM, None)]
297}
298
299pub fn series_symbol_segments(cfg: &SeriesConfig) -> Vec<RichSegment> {
302 match &cfg.render_type {
303 DataRenderType::Line { line } => line_symbol(line.line_color, line.line_style),
304 DataRenderType::Histogram { bar } => {
307 scatter_symbol(&ScatterShape::SquareFilled, bar.fill_color)
308 }
309 DataRenderType::Contour { contour, .. }
312 | DataRenderType::HeatmapContour { contour, .. } => {
313 line_symbol(contour.line.line_color, contour.line.line_style)
314 }
315 DataRenderType::Heatmap { .. } => blank_symbol(),
316 DataRenderType::Scatter { scatter }
317 | DataRenderType::ScatterErrorbarX { scatter, .. }
318 | DataRenderType::ScatterErrorbarY { scatter, .. }
319 | DataRenderType::ScatterErrorbarXY { scatter, .. } => {
320 scatter_symbol(&scatter.point_shape, scatter.point_color)
321 }
322 DataRenderType::ScatterLine { scatter, line }
323 | DataRenderType::LineScatterErrorbarX { scatter, line, .. }
324 | DataRenderType::LineScatterErrorbarY { scatter, line, .. }
325 | DataRenderType::LineScatterErrorbarXY { scatter, line, .. } => {
326 line_scatter_symbol_with_style(&scatter.point_shape, line.line_color, line.line_style)
327 }
328 }
329}
330
331pub fn append_legend_entry(content: &mut RichText, symbol: Vec<RichSegment>, label: &str) {
342 append_legend_entry_rich(content, symbol, rich_segments_from_text(label));
343}
344
345pub fn append_legend_entry_rich(
347 content: &mut RichText,
348 symbol: Vec<RichSegment>,
349 label: Vec<RichSegment>,
350) {
351 if !content.segments.is_empty() {
352 content.segments.push(RichSegment::plain('\n'));
353 }
354 content.segments.extend(symbol);
355 content.segments.push(RichSegment::plain(' '));
356 content.segments.push(RichSegment::plain('\t'));
357 content.segments.extend(label);
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
361struct LegendEntrySpan {
362 symbol: Range<usize>,
363 text: Range<usize>,
364 remove: Range<usize>,
365}
366
367fn is_plain_space(seg: &RichSegment) -> bool {
368 seg.text == ' '
369 && !seg.bold
370 && !seg.italic
371 && !seg.underline
372 && !seg.superscript
373 && !seg.subscript
374 && !seg.greek
375 && seg.color.is_none()
376 && seg.font_size.is_none()
377 && seg.field_em.is_none()
378 && !seg.rule
379 && seg.rule_dash.is_none()
380}
381
382fn symbol_anchor(segments: &[RichSegment], start: usize) -> Option<(usize, usize)> {
383 if start >= segments.len() || segments[start].field_em.is_none() {
384 return None;
385 }
386
387 let mut i = start;
388 while i < segments.len() && segments[i].field_em.is_some() {
389 i += 1;
390 }
391 let symbol_end = i;
392 while i < segments.len() && is_plain_space(&segments[i]) {
393 i += 1;
394 }
395 (i < segments.len() && segments[i].text == '\t').then_some((symbol_end, i))
396}
397
398fn legend_entry_spans(content: &RichText) -> Vec<LegendEntrySpan> {
399 let segments = &content.segments;
400 let mut anchors = Vec::new();
401 let mut i = 0;
402 while i < segments.len() {
403 if let Some((symbol_end, tab)) = symbol_anchor(segments, i) {
404 anchors.push((i, symbol_end, tab));
405 i = tab + 1;
406 } else {
407 i += 1;
408 }
409 }
410
411 let mut spans = Vec::with_capacity(anchors.len());
412 for (idx, &(symbol_start, symbol_end, tab)) in anchors.iter().enumerate() {
413 let next_start = anchors.get(idx + 1).map(|(start, _, _)| *start);
414 let mut text_end = next_start.unwrap_or(segments.len());
415 if next_start.is_some() && text_end > tab + 1 && segments[text_end - 1].text == '\n' {
416 text_end -= 1;
417 }
418
419 let remove_start = if symbol_start > 0 && segments[symbol_start - 1].text == '\n' {
420 symbol_start - 1
421 } else {
422 symbol_start
423 };
424 let remove_end = if idx == 0 {
425 next_start
426 .filter(|&start| start > 0 && segments[start - 1].text == '\n')
427 .unwrap_or(text_end)
428 } else {
429 text_end
430 };
431
432 spans.push(LegendEntrySpan {
433 symbol: symbol_start..symbol_end,
434 text: tab + 1..text_end,
435 remove: remove_start..remove_end,
436 });
437 }
438 spans
439}
440
441pub fn legend_entry_count(content: &RichText) -> usize {
443 legend_entry_spans(content).len()
444}
445
446pub fn update_legend_symbols_preserving_text(
450 content: &mut RichText,
451 series: &[SeriesConfig],
452) -> usize {
453 let spans = legend_entry_spans(content);
454 let mut replaced = 0;
455 for (span, cfg) in spans.into_iter().zip(series.iter()).rev() {
456 content
457 .segments
458 .splice(span.symbol, series_symbol_segments(cfg));
459 replaced += 1;
460 }
461 replaced
462}
463
464pub fn set_legend_entry_label(
467 content: &mut RichText,
468 entry_index: usize,
469 symbol: Vec<RichSegment>,
470 label: Vec<RichSegment>,
471) {
472 let spans = legend_entry_spans(content);
473 if let Some(span) = spans.get(entry_index) {
474 content.segments.splice(span.text.clone(), label);
475 } else {
476 append_legend_entry_rich(content, symbol, label);
477 }
478}
479
480pub fn remove_legend_entry(content: &mut RichText, entry_index: usize) -> bool {
483 let spans = legend_entry_spans(content);
484 let Some(span) = spans.get(entry_index) else {
485 return false;
486 };
487 content.segments.drain(span.remove.clone());
488 true
489}
490
491#[derive(Debug, Clone, PartialEq)]
493#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
494pub struct Legend {
495 pub visible: bool,
496 pub content: RichText,
503 pub corner: LegendCorner,
504 pub offset_x: f32,
506 pub offset_y: f32,
507 pub padding: f32,
509 pub bg_color: Color,
511 pub border_color: Color,
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::data_config::DataLineStyleConfig;
518 use crate::line::LineStylePreset;
519
520 fn line_cfg(color: Color) -> SeriesConfig {
521 line_cfg_style(color, LineStylePreset::Solid)
522 }
523
524 fn line_cfg_style(color: Color, line_style: LineStylePreset) -> SeriesConfig {
525 SeriesConfig {
526 series_id: "s".into(),
527 source_id: None,
528 label: None,
529 x_column: "x".into(),
530 y_column: "y".into(),
531 render_type: DataRenderType::Line {
532 line: DataLineStyleConfig {
533 line_style,
534 line_color: color,
535 line_width: 1.0,
536 },
537 },
538 }
539 }
540
541 fn empty_content() -> RichText {
542 RichText {
543 segments: Vec::new(),
544 color: Color::BLACK,
545 font_size: 14.0,
546 font: String::new(),
547 }
548 }
549
550 #[test]
551 fn series_symbol_takes_series_color_as_override() {
552 let red = Color::new(1.0, 0.0, 0.0, 1.0);
553 let segs = series_symbol_segments(&line_cfg(red));
554 assert_eq!(segs.len(), 1); assert_eq!(segs[0].text, '—');
556 assert_eq!(segs[0].color, Some(red));
557 assert_eq!(segs[0].font_size, None); }
559
560 #[test]
561 fn scatter_symbols_map_every_shape_to_a_glyph() {
562 for shape in [
563 ScatterShape::Circle,
564 ScatterShape::Square,
565 ScatterShape::Triangle,
566 ScatterShape::TriangleDown,
567 ScatterShape::TriangleLeft,
568 ScatterShape::TriangleRight,
569 ScatterShape::Diamond,
570 ScatterShape::Cross,
571 ScatterShape::Plus,
572 ScatterShape::Pentagon,
573 ScatterShape::Hexagon,
574 ScatterShape::Octagon,
575 ScatterShape::Star,
576 ScatterShape::CircleFilled,
577 ScatterShape::SquareFilled,
578 ScatterShape::TriangleFilled,
579 ScatterShape::TriangleDownFilled,
580 ScatterShape::TriangleLeftFilled,
581 ScatterShape::TriangleRightFilled,
582 ScatterShape::DiamondFilled,
583 ScatterShape::CrossFilled,
584 ScatterShape::PlusFilled,
585 ScatterShape::PentagonFilled,
586 ScatterShape::HexagonFilled,
587 ScatterShape::OctagonFilled,
588 ScatterShape::StarFilled,
589 ] {
590 let c = scatter_shape_char(&shape);
591 assert!(!c.is_control(), "{shape:?} → {c}");
592 }
593 }
594
595 #[test]
596 fn kind_symbols_carry_color_override_on_every_segment() {
597 let blue = Color::new(0.0, 0.0, 1.0, 1.0);
598 for kind in [
599 LegendEntryKind::Line,
600 LegendEntryKind::Scatter,
601 LegendEntryKind::LineScatter,
602 ] {
603 let segs = symbol_segments(&kind, blue);
604 assert!(!segs.is_empty());
605 assert!(segs.iter().all(|s| s.color == Some(blue)), "{kind:?}");
606 }
607 }
608
609 #[test]
610 fn two_appends_put_explicit_newline_between_entries() {
611 let mut content = empty_content();
612 append_legend_entry(
613 &mut content,
614 symbol_segments(&LegendEntryKind::Line, Color::BLACK),
615 "a",
616 );
617 append_legend_entry(
618 &mut content,
619 symbol_segments(&LegendEntryKind::Line, Color::BLACK),
620 "b",
621 );
622
623 let newlines = content.segments.iter().filter(|s| s.text == '\n').count();
624 assert_eq!(newlines, 1);
625 let texts: String = content.segments.iter().map(|s| s.text).collect();
628 assert_eq!(texts, "— \ta\n— \tb");
629 assert!(
631 content
632 .segments
633 .iter()
634 .filter(|s| s.text == 'a')
635 .all(|s| s.color.is_none())
636 );
637 }
638
639 #[test]
640 fn label_newlines_stay_explicit_in_content() {
641 let mut content = empty_content();
642 append_legend_entry(
643 &mut content,
644 symbol_segments(&LegendEntryKind::Line, Color::BLACK),
645 "line 1\nline 2",
646 );
647 let newlines = content.segments.iter().filter(|s| s.text == '\n').count();
648 assert_eq!(newlines, 1);
649 }
650
651 #[test]
652 fn one_line_composition_when_caller_joins_without_newline() {
653 let red = Color::new(1.0, 0.0, 0.0, 1.0);
656 let mut content = empty_content();
657 append_legend_entry(
658 &mut content,
659 symbol_segments(&LegendEntryKind::Line, red),
660 "a",
661 );
662 content.segments.push(RichSegment::plain(' '));
663 content
664 .segments
665 .extend(symbol_segments(&LegendEntryKind::Scatter, red));
666 content.segments.push(RichSegment::plain(' '));
667 content.segments.extend(rich_segments_from_text("b"));
668
669 assert!(content.segments.iter().all(|s| s.text != '\n'));
670 let texts: String = content.segments.iter().map(|s| s.text).collect();
671 assert_eq!(texts, "— \ta ● b");
672 }
673
674 fn field_series(render_type: DataRenderType) -> SeriesConfig {
675 SeriesConfig {
676 series_id: "f".into(),
677 source_id: None,
678 label: None,
679 x_column: "x".into(),
680 y_column: "y".into(),
681 render_type,
682 }
683 }
684
685 fn bar(color: Color) -> DataRenderType {
686 DataRenderType::Histogram {
687 bar: crate::data_config::DataBarStyleConfig {
688 fill_color: color,
689 border_color: Color::BLACK,
690 border_width: 1.0,
691 baseline: 0.0,
692 gap_px: 1.0,
693 width_ratio: 1.0,
694 orientation: crate::data_config::BarOrientation::Vertical,
695 bar_style_overrides: None,
696 },
697 }
698 }
699
700 fn contour(color: Color) -> crate::data_config::ContourConfig {
701 crate::data_config::ContourConfig {
702 levels: vec![1.0],
703 line: DataLineStyleConfig {
704 line_style: LineStylePreset::Dash,
705 line_color: color,
706 line_width: 1.0,
707 },
708 per_level_color: None,
709 labels: None,
710 }
711 }
712
713 fn matrix() -> crate::data_config::MatrixRef {
714 crate::data_config::MatrixRef {
715 columns: vec!["z0".into()],
716 orientation: crate::data_config::MatrixOrientation::ColumnsAreX,
717 grid_layout: crate::data_config::GridLayout::Centers,
718 }
719 }
720
721 fn fill() -> crate::data_config::FieldFillConfig {
722 crate::data_config::FieldFillConfig {
723 mode: crate::data_config::FillMode::Continuous,
724 shading: crate::data_config::Shading::Flat,
725 opacity: 1.0,
726 }
727 }
728
729 #[test]
732 fn field_series_symbols_come_from_the_primitive_that_is_drawn() {
733 let swatch = series_symbol_segments(&field_series(bar(Color::from_rgb8(1, 2, 3))));
734 assert_eq!(swatch.len(), 1);
735 assert_eq!(swatch[0].color, Some(Color::from_rgb8(1, 2, 3)));
736 assert_eq!(
737 swatch[0].text,
738 scatter_shape_char(&crate::data_config::ScatterShape::SquareFilled)
739 );
740
741 let level_color = Color::from_rgb8(4, 5, 6);
742 for render_type in [
743 DataRenderType::Contour {
744 matrix: matrix(),
745 contour: contour(level_color),
746 },
747 DataRenderType::HeatmapContour {
748 matrix: matrix(),
749 fill: fill(),
750 contour: contour(level_color),
751 },
752 ] {
753 let mark = series_symbol_segments(&field_series(render_type));
754 assert_eq!(mark.len(), 1, "a contour mark is one rule");
755 assert_eq!(mark[0].color, Some(level_color));
756 assert!(mark[0].rule, "a contour mark is a drawn rule");
759 assert!(mark[0].rule_dash.is_some(), "dashed contour, dashed mark");
760 }
761 }
762
763 #[test]
768 fn a_heatmap_gets_a_blank_full_width_mark_not_no_entry() {
769 let heatmap = field_series(DataRenderType::Heatmap {
770 matrix: matrix(),
771 fill: fill(),
772 });
773 let mark = series_symbol_segments(&heatmap);
774 assert_eq!(mark.len(), 1);
775 assert_eq!(mark[0].field_em, Some(SYMBOL_FIELD_EM));
776 assert_eq!(mark[0].text, ' ');
777
778 let series = [
780 line_cfg(Color::from_rgb8(10, 0, 0)),
781 heatmap,
782 field_series(bar(Color::from_rgb8(0, 10, 0))),
783 ];
784 let mut content = RichText {
785 segments: Vec::new(),
786 color: Color::BLACK,
787 font_size: 12.0,
788 font: String::new(),
789 };
790 for cfg in &series {
791 append_legend_entry(&mut content, series_symbol_segments(cfg), "label");
792 }
793 assert_eq!(legend_entry_count(&content), series.len());
794 assert_eq!(
795 update_legend_symbols_preserving_text(&mut content, &series),
796 series.len(),
797 "every series must own exactly one entry, or later symbols shift"
798 );
799 }
800}