1use std::ops::{BitOr, Div};
27
28use crate::aes::Aesthetic;
29use crate::guide::legend;
30use crate::plot::{GGError, GGPlot, RenderMeta};
31use crate::render::backend::{
32 DrawBackend, LineStyle, PointStyle, RectStyle, TextAnchor, TextStyle,
33};
34use crate::render::renderer::PlotRenderer;
35use crate::render::svg_backend::{root_data_attrs, validate_root_attrs, SvgBackend};
36use crate::render::{Rect, RenderError};
37use crate::theme::elements::{ElementLine, ElementText};
38use crate::theme::{AxisLabelFit, LegendPosition, Theme};
39
40#[derive(Clone, Debug, PartialEq)]
42pub enum TagLevels {
43 Lower,
45 Upper,
47 Numeric,
49 LowerRoman,
51 UpperRoman,
53 Custom(Vec<String>),
55}
56
57impl TagLevels {
58 pub fn tag(&self, i: usize) -> String {
60 match self {
61 TagLevels::Lower => alpha(i, b'a'),
62 TagLevels::Upper => alpha(i, b'A'),
63 TagLevels::Numeric => (i + 1).to_string(),
64 TagLevels::LowerRoman => roman(i + 1).to_lowercase(),
65 TagLevels::UpperRoman => roman(i + 1),
66 TagLevels::Custom(v) => v.get(i).cloned().unwrap_or_else(|| (i + 1).to_string()),
67 }
68 }
69}
70
71fn alpha(mut i: usize, base: u8) -> String {
72 let mut out = Vec::new();
74 loop {
75 out.push(base + (i % 26) as u8);
76 if i < 26 {
77 break;
78 }
79 i = i / 26 - 1;
80 }
81 out.reverse();
82 String::from_utf8(out).unwrap_or_default()
83}
84
85fn roman(mut n: usize) -> String {
86 if n >= 4000 {
87 return n.to_string();
88 }
89 const T: [(usize, &str); 13] = [
90 (1000, "M"),
91 (900, "CM"),
92 (500, "D"),
93 (400, "CD"),
94 (100, "C"),
95 (90, "XC"),
96 (50, "L"),
97 (40, "XL"),
98 (10, "X"),
99 (9, "IX"),
100 (5, "V"),
101 (4, "IV"),
102 (1, "I"),
103 ];
104 let mut s = String::new();
105 for (v, r) in T {
106 while n >= v {
107 s.push_str(r);
108 n -= v;
109 }
110 }
111 s
112}
113
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
122pub enum AxisCollect {
123 #[default]
125 Keep,
126 Align,
141 CollectX,
148}
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum GridLegendPosition {
153 #[default]
155 Right,
156 Bottom,
158}
159
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163enum Op {
164 Row,
165 Col,
166}
167
168pub enum GridCell {
170 Plot(Box<GGPlot>),
171 Grid(Box<PlotGrid>),
172 Spacer,
174 Element(Box<GridElement>),
177}
178
179enum ElementContent {
181 Fixed {
184 svg: String,
185 width: f64,
186 height: f64,
187 },
188 Fill(Box<dyn Fn(f64, f64) -> String + Send + Sync>),
190}
191
192pub struct GridElement {
200 content: ElementContent,
201}
202
203impl GridElement {
204 pub fn fixed(svg: impl Into<String>, width: f64, height: f64) -> Self {
207 GridElement {
208 content: ElementContent::Fixed {
209 svg: svg.into(),
210 width,
211 height,
212 },
213 }
214 }
215
216 pub fn fill(render: impl Fn(f64, f64) -> String + Send + Sync + 'static) -> Self {
219 GridElement {
220 content: ElementContent::Fill(Box::new(render)),
221 }
222 }
223}
224
225impl From<GridElement> for GridCell {
226 fn from(e: GridElement) -> Self {
227 GridCell::Element(Box::new(e))
228 }
229}
230
231impl From<GGPlot> for GridCell {
232 fn from(p: GGPlot) -> Self {
233 GridCell::Plot(Box::new(p))
234 }
235}
236
237impl From<PlotGrid> for GridCell {
238 fn from(g: PlotGrid) -> Self {
239 GridCell::Grid(Box::new(g))
240 }
241}
242
243pub struct PlotGrid {
262 cells: Vec<GridCell>,
263 ncol: Option<usize>,
264 nrow: Option<usize>,
265 byrow: bool,
266 widths: Vec<f64>,
267 heights: Vec<f64>,
268 title: Option<String>,
269 subtitle: Option<String>,
270 caption: Option<String>,
271 tags: Option<TagLevels>,
272 tag_prefix: String,
273 tag_suffix: String,
274 collect_legends: bool,
275 legend_position: GridLegendPosition,
276 legend_bottom_share: Option<f64>,
278 spacing: f64,
279 theme: Theme,
280 axis_collect: AxisCollect,
281 op: Option<Op>,
282 root_attrs: Vec<(String, String)>,
285 svg_warnings_attr: bool,
290}
291
292impl Default for PlotGrid {
293 fn default() -> Self {
294 PlotGrid {
295 cells: Vec::new(),
296 ncol: None,
297 nrow: None,
298 byrow: true,
299 widths: Vec::new(),
300 heights: Vec::new(),
301 title: None,
302 subtitle: None,
303 caption: None,
304 tags: None,
305 tag_prefix: String::new(),
306 tag_suffix: String::new(),
307 collect_legends: false,
308 legend_position: GridLegendPosition::Right,
309 legend_bottom_share: None,
310 spacing: 0.0,
311 theme: Theme::default(),
312 axis_collect: AxisCollect::Keep,
313 op: None,
314 root_attrs: Vec::new(),
315 svg_warnings_attr: false,
316 }
317 }
318}
319
320const MAX_TRACKS: usize = 10_000;
322
323impl PlotGrid {
324 pub fn new() -> Self {
325 Self::default()
326 }
327
328 #[allow(clippy::should_implement_trait)]
330 pub fn add(mut self, cell: impl Into<GridCell>) -> Self {
331 self.cells.push(cell.into());
332 self
333 }
334
335 pub fn add_all(mut self, plots: impl IntoIterator<Item = GGPlot>) -> Self {
337 self.cells.extend(plots.into_iter().map(GridCell::from));
338 self
339 }
340
341 pub fn add_spacer(mut self) -> Self {
343 self.cells.push(GridCell::Spacer);
344 self
345 }
346
347 pub fn ncol(mut self, n: usize) -> Self {
349 self.ncol = (n > 0).then_some(n.min(MAX_TRACKS));
350 self
351 }
352
353 pub fn nrow(mut self, n: usize) -> Self {
355 self.nrow = (n > 0).then_some(n.min(MAX_TRACKS));
356 self
357 }
358
359 pub fn byrow(mut self, byrow: bool) -> Self {
361 self.byrow = byrow;
362 self
363 }
364
365 pub fn widths(mut self, widths: &[f64]) -> Self {
367 self.widths = widths.to_vec();
368 self
369 }
370
371 pub fn heights(mut self, heights: &[f64]) -> Self {
373 self.heights = heights.to_vec();
374 self
375 }
376
377 pub fn title(mut self, title: &str) -> Self {
379 self.title = Some(title.to_string());
380 self
381 }
382
383 pub fn subtitle(mut self, subtitle: &str) -> Self {
385 self.subtitle = Some(subtitle.to_string());
386 self
387 }
388
389 pub fn caption(mut self, caption: &str) -> Self {
391 self.caption = Some(caption.to_string());
392 self
393 }
394
395 pub fn tag_levels(mut self, levels: TagLevels) -> Self {
397 self.tags = Some(levels);
398 self
399 }
400
401 pub fn tag_affixes(mut self, prefix: &str, suffix: &str) -> Self {
403 self.tag_prefix = prefix.to_string();
404 self.tag_suffix = suffix.to_string();
405 self
406 }
407
408 pub fn collect_legends(mut self, collect: bool) -> Self {
411 self.collect_legends = collect;
412 self
413 }
414
415 pub fn legend_position(mut self, pos: GridLegendPosition) -> Self {
417 self.legend_position = pos;
418 self
419 }
420
421 pub fn legend_bottom_when_narrow(mut self, share: f64) -> Self {
429 self.legend_bottom_share = Some(share);
430 self
431 }
432
433 pub fn axis_collect(mut self, mode: AxisCollect) -> Self {
437 self.axis_collect = mode;
438 self
439 }
440
441 pub fn spacing(mut self, px: f64) -> Self {
443 self.spacing = if px.is_finite() { px.max(0.0) } else { 0.0 };
444 self
445 }
446
447 pub fn theme(mut self, theme: Theme) -> Self {
450 self.theme = theme;
451 self
452 }
453
454 pub fn svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
461 self.root_attrs.push((name.into(), value.into()));
462 self
463 }
464
465 pub fn svg_attrs(mut self, attrs: impl IntoIterator<Item = (String, String)>) -> Self {
467 self.root_attrs.extend(attrs);
468 self
469 }
470
471 pub fn svg_warnings_attr(mut self, on: bool) -> Self {
475 self.svg_warnings_attr = on;
476 self
477 }
478
479 pub fn len(&self) -> usize {
481 self.cells.len()
482 }
483
484 pub fn is_empty(&self) -> bool {
486 self.cells.is_empty()
487 }
488
489 pub fn render_svg_native(self) -> Result<String, GGError> {
491 self.render_svg_native_with_size(800, 600)
492 }
493
494 pub fn render_svg_native_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
498 Ok(self.render_page(w, h)?.0.finish())
499 }
500
501 pub fn render_svg_native_with_warnings(
504 self,
505 w: u32,
506 h: u32,
507 ) -> Result<(String, Vec<String>), GGError> {
508 let (page, warnings) = self.render_page(w, h)?;
509 Ok((page.finish(), warnings))
510 }
511
512 pub fn render_svg_native_at(self, x: f64, y: f64, w: u32, h: u32) -> Result<String, GGError> {
515 Ok(self.render_page(w, h)?.0.finish_fragment(x, y))
516 }
517
518 fn render_page(self, w: u32, h: u32) -> Result<(SvgBackend, Vec<String>), GGError> {
519 let (w, h) = (w.max(1), h.max(1));
520 let mut theme = self.theme.clone();
521 theme.resolve_inheritance();
522 let collect = self.collect_legends;
523 let mut legend_pos = self.legend_position;
524 let alt_pos = self
527 .legend_bottom_share
528 .filter(|_| legend_pos == GridLegendPosition::Right)
529 .map(|share| (share, GridLegendPosition::Bottom));
530 let root_attrs = self.root_attrs.clone();
531 let svg_warnings_attr = self.svg_warnings_attr;
532
533 let mut ctx = PrepCtx {
536 tags: self
537 .tags
538 .clone()
539 .map(|t| (t, self.tag_prefix.clone(), self.tag_suffix.clone())),
540 next_tag: 0,
541 next_index: 0,
542 collect: collect.then_some(legend_pos),
543 collect_alt: alt_pos.map(|(_, p)| p),
544 legends: Vec::new(),
545 legend_sigs: Vec::new(),
546 page_w: w as f64,
547 page_h: h as f64,
548 legend_warnings: Vec::new(),
549 };
550 let prepared = prepare_grid(self, &mut ctx)?;
551 let (mut legends, alt_legends): (Vec<Legend>, Vec<Option<Legend>>) =
552 ctx.legends.into_iter().unzip();
553 if let Some((share, alt)) = alt_pos {
554 let strip_w = legends.iter().map(|l| l.w).fold(0.0, f64::max) + 2.0 * LEGEND_PAD;
555 if !legends.is_empty() && strip_w > share * w as f64 {
556 legend_pos = alt;
557 legends = alt_legends.into_iter().flatten().collect();
558 }
559 }
560 let legend_warnings = ctx.legend_warnings;
561
562 let full = Rect {
564 x: 0.0,
565 y: 0.0,
566 width: w as f64,
567 height: h as f64,
568 };
569 let mut page = SvgBackend::new(w, h, full.clone());
570 page.without_plot_attr();
571 let (rows, cols) = prepared.dims();
572 let data_grid_attr = ("data-grid".to_string(), format!("{rows} {cols}"));
573 page.set_root_attrs(vec![data_grid_attr.clone()]);
574 if let Some(fill) = theme
575 .plot_background
576 .fill
577 .filter(|_| theme.plot_background.visible)
578 {
579 page.draw_rect(
580 (0.0, 0.0),
581 (w as f64, h as f64),
582 &RectStyle {
583 fill: Some(fill),
584 stroke: None,
585 stroke_width: 0.0,
586 alpha: 1.0,
587 clip: false,
588 corner_radius: 0.0,
589 },
590 )
591 .map_err(GGError::Render)?;
592 }
593
594 let mut body = full;
595 if !legends.is_empty() {
596 let gap = theme.legend_spacing.max(0.0) * 2.0;
597 let pad = LEGEND_PAD;
598 match legend_pos {
599 GridLegendPosition::Right => {
600 let strip_w = legends.iter().map(|l| l.w).fold(0.0, f64::max) + 2.0 * pad;
601 let strip_w = strip_w.min(body.width * 0.4);
602 body.width -= strip_w;
603 let total_h =
604 legends.iter().map(|l| l.h).sum::<f64>() + gap * (legends.len() - 1) as f64;
605 let mut y = (body.y + (body.height - total_h) / 2.0).max(body.y + pad);
606 for l in &legends {
607 place_legend(&mut page, l, body.x + body.width + pad, y);
608 y += l.h + gap;
609 }
610 }
611 GridLegendPosition::Bottom => {
612 let strip_h = legends.iter().map(|l| l.h).fold(0.0, f64::max) + 2.0 * pad;
613 let strip_h = strip_h.min(body.height * 0.4);
614 body.height -= strip_h;
615 let total_w =
616 legends.iter().map(|l| l.w).sum::<f64>() + gap * (legends.len() - 1) as f64;
617 let mut x = (body.x + (body.width - total_w) / 2.0).max(body.x + pad);
618 for l in &legends {
619 place_legend(&mut page, l, x, body.y + body.height + pad);
620 x += l.w + gap;
621 }
622 }
623 }
624 }
625
626 let mut warnings = legend_warnings;
628 render_prepared(prepared, &body, &mut page, &mut warnings)?;
629
630 let mut reserved = vec![data_grid_attr];
631 if svg_warnings_attr {
632 reserved.push(("data-warnings".to_string(), String::new()));
633 }
634 let extra = crate::render::svg_backend::validate_root_attrs(
635 &root_attrs,
636 &reserved,
637 "grid",
638 &mut warnings,
639 );
640 page.extend_root_attrs(extra);
641 if svg_warnings_attr {
642 page.extend_root_attrs(vec![(
643 "data-warnings".to_string(),
644 crate::render::svg_backend::warnings_json(&warnings),
645 )]);
646 }
647 Ok((page, warnings))
648 }
649}
650
651const LEGEND_PAD: f64 = 8.0;
653
654struct Legend {
656 body: String,
657 x0: f64,
658 y0: f64,
659 w: f64,
660 h: f64,
661}
662
663fn place_legend(page: &mut SvgBackend, l: &Legend, x: f64, y: f64) {
664 let (tx, ty) = (x - l.x0, y - l.y0);
665 if tx.is_finite() && ty.is_finite() {
666 page.push_raw(&format!(
667 "<g class=\"legend\" transform=\"translate({tx:.2} {ty:.2})\">{}</g>",
668 l.body
669 ));
670 }
671}
672
673struct PrepCtx {
674 tags: Option<(TagLevels, String, String)>,
675 next_tag: usize,
676 next_index: usize,
677 collect: Option<GridLegendPosition>,
678 collect_alt: Option<GridLegendPosition>,
681 legends: Vec<(Legend, Option<Legend>)>,
683 legend_sigs: Vec<String>,
687 page_w: f64,
693 page_h: f64,
694 legend_warnings: Vec<String>,
700}
701
702struct PreparedPlot {
704 built: crate::build::BuiltPlot,
705 meta: RenderMeta,
706 panel: String,
707 svg_formatted_values: bool,
715 root_attrs: Vec<(String, String)>,
723}
724
725struct PreparedElement {
727 element: GridElement,
728 index: usize,
729 tag: Option<String>,
730 theme: Theme,
732}
733
734enum Prepared {
735 Plot(Box<PreparedPlot>),
736 Element(Box<PreparedElement>),
737 Grid(Box<PreparedGrid>),
738 Spacer,
739}
740
741struct PreparedGrid {
742 cells: Vec<Prepared>,
743 ncol: usize,
744 nrow: usize,
745 byrow: bool,
746 widths: Vec<f64>,
747 heights: Vec<f64>,
748 title: Option<String>,
749 subtitle: Option<String>,
750 caption: Option<String>,
751 spacing: f64,
752 theme: Theme,
753 axis_collect: AxisCollect,
754}
755
756impl Prepared {
757 fn dims(&self) -> (usize, usize) {
758 match self {
759 Prepared::Grid(g) => (g.nrow, g.ncol),
760 _ => (1, 1),
761 }
762 }
763}
764
765fn prepare_grid(grid: PlotGrid, ctx: &mut PrepCtx) -> Result<Prepared, GGError> {
766 let n = grid.cells.len();
767 let (ncol, nrow) = match (grid.ncol, grid.nrow, grid.op) {
768 (Some(c), Some(r), _) => {
769 (c, r.max(n.div_ceil(c)))
771 }
772 (Some(c), None, _) => (c, n.div_ceil(c).max(1)),
773 (None, Some(r), _) => (n.div_ceil(r).max(1), r),
774 (None, None, Some(Op::Row)) => (n.max(1), 1),
775 (None, None, Some(Op::Col)) => (1, n.max(1)),
776 (None, None, None) => {
777 let c = ((n as f64).sqrt().ceil() as usize).max(1);
778 (c, n.div_ceil(c).max(1))
779 }
780 };
781 let mut theme = grid.theme;
782 theme.resolve_inheritance();
783 let mut cells = Vec::with_capacity(n);
784 for cell in grid.cells {
785 cells.push(match cell {
786 GridCell::Spacer => Prepared::Spacer,
787 GridCell::Grid(g) => prepare_grid(*g, ctx)?,
788 GridCell::Plot(p) => Prepared::Plot(Box::new(prepare_plot(*p, ctx)?)),
789 GridCell::Element(e) => {
790 ctx.next_index += 1;
791 let tag = ctx
792 .tags
793 .as_ref()
794 .map(|(levels, pre, suf)| format!("{pre}{}{suf}", levels.tag(ctx.next_tag)));
795 if tag.is_some() {
796 ctx.next_tag += 1;
797 }
798 Prepared::Element(Box::new(PreparedElement {
799 element: *e,
800 index: ctx.next_index,
801 tag,
802 theme: theme.clone(),
803 }))
804 }
805 });
806 }
807 Ok(Prepared::Grid(Box::new(PreparedGrid {
808 cells,
809 ncol,
810 nrow,
811 byrow: grid.byrow,
812 widths: grid.widths,
813 heights: grid.heights,
814 title: grid.title,
815 subtitle: grid.subtitle,
816 caption: grid.caption,
817 spacing: grid.spacing,
818 theme,
819 axis_collect: grid.axis_collect,
820 })))
821}
822
823fn prepare_plot(mut plot: GGPlot, ctx: &mut PrepCtx) -> Result<PreparedPlot, GGError> {
824 ctx.next_index += 1;
825 if let Some((levels, pre, suf)) = &ctx.tags {
826 plot.labels.tag = Some(format!("{pre}{}{suf}", levels.tag(ctx.next_tag)));
827 ctx.next_tag += 1;
828 }
829 let panel = plot
830 .labels
831 .tag
832 .clone()
833 .unwrap_or_else(|| ctx.next_index.to_string());
834
835 let original = plot.theme.legend_position.clone();
837 let collect = ctx.collect.filter(|_| {
838 matches!(
839 original,
840 LegendPosition::Right
841 | LegendPosition::Left
842 | LegendPosition::Top
843 | LegendPosition::Bottom
844 )
845 });
846 if collect.is_some() {
847 plot.theme.legend_position = LegendPosition::None;
848 }
849 let svg_formatted_values = plot.svg_formatted_values;
850 let root_attrs = plot.root_attrs.clone();
851 let (mut built, mut meta) = plot.build_for_render()?;
852 if let Some(pos) = collect {
853 meta.has_legend = false;
854 if let Some(l) = capture_legend(
855 &built,
856 pos,
857 ctx.page_w,
858 ctx.page_h,
859 &mut ctx.legend_warnings,
860 )
861 .map_err(GGError::Render)?
862 {
863 let keys = std::mem::take(&mut built.guide_legend.layer_keys);
864 let sig = capture_legend(&built, pos, ctx.page_w, ctx.page_h, &mut Vec::new())
865 .map_err(GGError::Render)?
866 .map(|s| s.body)
867 .unwrap_or_default();
868 built.guide_legend.layer_keys = keys;
869 if !ctx.legend_sigs.contains(&sig) {
870 ctx.legend_sigs.push(sig);
871 let alt = match ctx.collect_alt {
872 Some(alt) => {
873 capture_legend(&built, alt, ctx.page_w, ctx.page_h, &mut Vec::new())
874 .map_err(GGError::Render)?
875 }
876 None => None,
877 };
878 ctx.legends.push((l, alt));
879 }
880 }
881 }
882 Ok(PreparedPlot {
883 built,
884 meta,
885 panel,
886 svg_formatted_values,
887 root_attrs,
888 })
889}
890
891fn capture_legend(
899 built: &crate::build::BuiltPlot,
900 pos: GridLegendPosition,
901 page_w: f64,
902 page_h: f64,
903 warnings: &mut Vec<String>,
911) -> Result<Option<Legend>, RenderError> {
912 let mut theme = built.theme.clone();
913 let m = theme.legend_margin.clone();
914 let (pa, lp, is_horizontal) = match pos {
916 GridLegendPosition::Right => (
917 Rect {
918 x: -m.left,
919 y: -m.top,
920 width: 0.0,
921 height: 0.0,
922 },
923 LegendPosition::Right,
924 false,
925 ),
926 GridLegendPosition::Bottom => (
927 Rect {
928 x: -m.left,
929 y: -(m.top + 30.0),
930 width: 0.0,
931 height: 0.0,
932 },
933 LegendPosition::Bottom,
934 true,
935 ),
936 };
937 theme.legend_position = lp;
938 let mut svg = SvgBackend::new(1, 1, pa.clone());
939 let mut meas = Measure::new(&mut svg, pa.clone());
940 let avail_width = (page_w + m.left).max(0.0);
941 let avail_height = (page_h + m.top).max(0.0);
942 let legend_plans = legend::plan_all_legends(
943 &built.scales,
944 &built.guide_legend,
945 &theme,
946 &built.suppressed_aes,
947 avail_width,
948 avail_height,
949 is_horizontal,
950 warnings,
951 );
952 legend::draw_legend_planned(
953 &built.scales,
954 &theme,
955 &pa,
956 avail_width,
963 &mut meas,
964 &built.guide_legend,
965 &built.suppressed_aes,
966 &legend_plans,
967 )?;
968 let ext = meas.extent();
969 if svg.body().is_empty() {
970 return Ok(None);
971 }
972 let (x0, y0, x1, y1) = match ext {
973 Some(e) => e,
974 None => return Ok(None),
975 };
976 Ok(Some(Legend {
977 body: svg.body().to_string(),
978 x0,
979 y0,
980 w: (x1 - x0).max(0.0),
981 h: (y1 - y0).max(0.0),
982 }))
983}
984
985fn tracks(weights: &[f64], n: usize, total: f64, gap: f64) -> Vec<(f64, f64)> {
987 let ws: Vec<f64> = (0..n)
988 .map(|i| {
989 if weights.is_empty() {
990 1.0
991 } else {
992 let w = weights[i % weights.len()];
993 if w.is_finite() && w > 0.0 {
994 w
995 } else {
996 1.0
997 }
998 }
999 })
1000 .collect();
1001 let sum: f64 = ws.iter().sum();
1002 let avail = (total - gap * n.saturating_sub(1) as f64).max(0.0);
1003 let mut pos = 0.0;
1004 ws.iter()
1005 .map(|w| {
1006 let size = avail * w / sum;
1007 let t = (pos, size);
1008 pos += size + gap;
1009 t
1010 })
1011 .collect()
1012}
1013
1014fn text_style(el: &crate::theme::elements::ElementText, anchor: TextAnchor) -> TextStyle {
1015 TextStyle {
1016 color: el.color,
1017 size: el.size,
1018 anchor,
1019 angle: 0.0,
1020 family: (!el.family.is_empty()).then(|| el.family.clone()),
1021 face: el.face,
1022 bg_colour: None,
1023 bg_r: 0.0,
1024 }
1025}
1026
1027fn hjust_x(el: &crate::theme::elements::ElementText, x0: f64, x1: f64) -> (f64, TextAnchor) {
1028 let hj = if el.hjust.is_finite() {
1029 el.hjust.clamp(0.0, 1.0)
1030 } else {
1031 0.0
1032 };
1033 let anchor = if hj <= 0.02 {
1034 TextAnchor::Start
1035 } else if hj >= 0.98 {
1036 TextAnchor::End
1037 } else {
1038 TextAnchor::Middle
1039 };
1040 (x0 + hj * (x1 - x0), anchor)
1041}
1042
1043fn render_prepared(
1044 prepared: Prepared,
1045 area: &Rect,
1046 page: &mut SvgBackend,
1047 warnings: &mut Vec<String>,
1048) -> Result<(), GGError> {
1049 match prepared {
1050 Prepared::Spacer => Ok(()),
1051 Prepared::Element(e) => {
1052 let PreparedElement {
1053 element,
1054 index,
1055 tag,
1056 theme,
1057 } = *e;
1058 let band = if tag.is_some() {
1063 (2.0 * theme.title.size).min(area.height.max(0.0))
1064 } else {
1065 0.0
1066 };
1067 let top = matches!(
1068 theme.tag_position,
1069 crate::theme::TagPosition::TopLeft | crate::theme::TagPosition::TopRight
1070 );
1071 let (x, w) = (area.x, area.width.max(0.0));
1072 let (y, h) = (
1073 if top { area.y + band } else { area.y },
1074 (area.height - band).max(0.0),
1075 );
1076 let (view_box, body) = match element.content {
1077 ElementContent::Fixed { svg, width, height } => {
1078 let ok = width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0;
1079 if !ok {
1080 warnings.push(format!(
1081 "plot_grid: element cell {index} has an invalid size {width}×{height}; skipped"
1082 ));
1083 return Ok(());
1084 }
1085 (format!(" viewBox=\"0 0 {width} {height}\""), svg)
1086 }
1087 ElementContent::Fill(render) => (String::new(), render(w, h)),
1088 };
1089 page.push_raw(&format!(
1090 "<svg x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\"{view_box} data-cell=\"{index}\">{body}</svg>"
1091 ));
1092 if let Some(tag) = tag {
1094 let labels = crate::plot::Labels {
1095 tag: Some(tag),
1096 ..Default::default()
1097 };
1098 PlotRenderer::draw_cell_tag(&labels, &theme, area, page)
1099 .map_err(GGError::Render)?;
1100 }
1101 Ok(())
1102 }
1103 Prepared::Plot(p) => {
1104 let PreparedPlot {
1105 mut built,
1106 meta,
1107 panel,
1108 svg_formatted_values,
1109 root_attrs,
1110 } = *p;
1111 let (cw, ch) = (
1112 area.width.floor().max(1.0) as u32,
1113 area.height.floor().max(1.0) as u32,
1114 );
1115 let layout = GGPlot::layout_built(&mut built, &meta, cw, ch);
1116 let mut backend = SvgBackend::new(cw, ch, layout.plot_area.clone());
1117 backend.set_formatted_values(svg_formatted_values);
1118 let mut attrs = root_data_attrs(&built);
1119 attrs.push(("data-panel".into(), panel.clone()));
1120 backend.set_root_attrs(attrs);
1121 PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
1122 let mut attr_warnings = Vec::new();
1129 let group_attrs = validate_root_attrs(&root_attrs, &[], "plot", &mut attr_warnings);
1130 backend.wrap_body_in_group(&group_attrs);
1131 warnings.extend(
1132 built
1133 .warnings
1134 .iter()
1135 .cloned()
1136 .chain(backend.take_warnings())
1137 .chain(attr_warnings)
1138 .map(|w| format!("panel {panel}: {w}")),
1139 );
1140 page.push_raw(&backend.finish_fragment(area.x, area.y));
1141 Ok(())
1142 }
1143 Prepared::Grid(g) => {
1144 let mut g = *g;
1145 let theme = &g.theme;
1146 let m = &theme.plot_margin;
1147 let mut top = area.y;
1149 let (x0, x1) = (area.x + m.left, area.x + area.width - m.right);
1150 if let Some(t) = &g.title {
1151 top += m.top;
1152 let (tx, anchor) = hjust_x(&theme.title, x0, x1);
1153 page.draw_text(
1154 t,
1155 (tx, top + theme.title.size * 0.8),
1156 &text_style(&theme.title, anchor),
1157 )
1158 .map_err(GGError::Render)?;
1159 top += theme.title.size * 1.6;
1160 }
1161 if let Some(s) = &g.subtitle {
1162 if g.title.is_none() {
1163 top += m.top;
1164 }
1165 let (sx, anchor) = hjust_x(&theme.subtitle, x0, x1);
1166 page.draw_text(
1167 s,
1168 (sx, top + theme.subtitle.size * 0.7),
1169 &text_style(&theme.subtitle, anchor),
1170 )
1171 .map_err(GGError::Render)?;
1172 top += theme.subtitle.size * 1.5;
1173 }
1174 let mut bottom = area.y + area.height;
1175 if let Some(c) = &g.caption {
1176 let (cx, anchor) = hjust_x(&theme.caption, x0, x1);
1177 bottom -= theme.caption.size * 1.8;
1178 page.draw_text(
1179 c,
1180 (cx, bottom + theme.caption.size * 0.9),
1181 &text_style(&theme.caption, anchor),
1182 )
1183 .map_err(GGError::Render)?;
1184 }
1185 let body_h = (bottom - top).max(1.0);
1186 let cols = tracks(&g.widths, g.ncol, area.width, g.spacing);
1187 let rows = tracks(&g.heights, g.nrow, body_h, g.spacing);
1188 if g.axis_collect != AxisCollect::Keep {
1189 align_axes(
1190 &mut g.cells,
1191 (g.ncol, g.nrow, g.byrow),
1192 &cols,
1193 &rows,
1194 g.axis_collect,
1195 warnings,
1196 );
1197 }
1198 for (k, cell) in g.cells.into_iter().enumerate() {
1199 let (r, c) = if g.byrow {
1200 (k / g.ncol, k % g.ncol)
1201 } else {
1202 (k % g.nrow, k / g.nrow)
1203 };
1204 if r >= g.nrow || c >= g.ncol {
1205 warnings.push(format!(
1207 "plot_grid: cell {} does not fit a {}×{} grid; skipped",
1208 k + 1,
1209 g.nrow,
1210 g.ncol
1211 ));
1212 continue;
1213 }
1214 let rect = Rect {
1215 x: area.x + cols[c].0,
1216 y: top + rows[r].0,
1217 width: cols[c].1,
1218 height: rows[r].1,
1219 };
1220 render_prepared(cell, &rect, page, warnings)?;
1221 }
1222 Ok(())
1223 }
1224 }
1225}
1226
1227fn align_axes(
1233 cells: &mut [Prepared],
1234 (ncol, nrow, byrow): (usize, usize, bool),
1237 cols: &[(f64, f64)],
1238 rows: &[(f64, f64)],
1239 mode: AxisCollect,
1240 warnings: &mut Vec<String>,
1241) {
1242 let idx_of = |r: usize, c: usize| -> usize {
1243 if byrow {
1244 r * ncol + c
1245 } else {
1246 c * nrow + r
1247 }
1248 };
1249 for c in 0..ncol {
1250 let cw = cols.get(c).map_or(0.0, |t| t.1);
1251 if !cw.is_finite() || cw <= 0.0 {
1252 continue;
1253 }
1254 let plot_cells: Vec<(usize, usize)> = (0..nrow)
1258 .filter_map(|r| {
1259 let k = idx_of(r, c);
1260 (k < cells.len() && matches!(cells[k], Prepared::Plot(_))).then_some((r, k))
1261 })
1262 .collect();
1263 if plot_cells.len() < 2 {
1264 continue; }
1266 let bottom_row = plot_cells.iter().map(|&(r, _)| r).max().unwrap();
1267
1268 for &(r, k) in &plot_cells {
1273 if let Prepared::Plot(p) = &mut cells[k] {
1274 p.built.theme.axis_text_x_fit = AxisLabelFit::Shift;
1275 if mode == AxisCollect::CollectX && r != bottom_row {
1276 p.built.theme.axis_text_x = ElementText::blank();
1277 p.built.theme.axis_title_x = ElementText::blank();
1278 p.built.theme.axis_ticks_x = Some(ElementLine::blank());
1279 }
1280 }
1281 }
1282
1283 let mut natural: Vec<(f64, f64, f64, f64, f64)> = Vec::with_capacity(plot_cells.len());
1292 for &(r, k) in &plot_cells {
1293 let ch = rows.get(r).map_or(0.0, |t| t.1);
1294 match &mut cells[k] {
1295 Prepared::Plot(p) => {
1296 let layout = GGPlot::layout_built(
1297 &mut p.built,
1298 &p.meta,
1299 cw.floor().max(1.0) as u32,
1300 ch.floor().max(1.0) as u32,
1301 );
1302 natural.push((
1303 layout.plot_area.x,
1304 layout.plot_area.x + layout.plot_area.width,
1305 layout.plot_area.y,
1306 layout.plot_area.height,
1307 ch,
1308 ));
1309 }
1310 _ => unreachable!("plot_cells only holds Prepared::Plot indices"),
1311 }
1312 }
1313
1314 let target_left = natural.iter().fold(f64::MIN, |m, &(l, ..)| m.max(l));
1319 let target_right_inset = natural
1320 .iter()
1321 .fold(f64::MIN, |m, &(_, r, ..)| m.max(cw - r));
1322 let width = cw - target_right_inset - target_left;
1323 if target_left.is_finite() && target_right_inset.is_finite() {
1324 for (&(_, k), &(nl, nr, ..)) in plot_cells.iter().zip(&natural) {
1325 if let Prepared::Plot(p) = &mut cells[k] {
1326 p.built.theme.plot_margin.left += (target_left - nl).max(0.0);
1327 p.built.theme.plot_margin.right += (target_right_inset - (cw - nr)).max(0.0);
1328 }
1329 }
1330 }
1331
1332 if width.is_finite() && width > 0.0 {
1343 for (&(_, k), &(_, _, y, h, ch)) in plot_cells.iter().zip(&natural) {
1344 if let Prepared::Plot(p) = &mut cells[k] {
1345 let bottom_budget = ch - p.built.theme.plot_margin.bottom;
1346 if y + h > bottom_budget + 0.5 {
1347 let fits = (ch - y - p.built.theme.plot_margin.bottom).max(8.0);
1348 p.built.theme.aspect_ratio = Some(fits / width);
1349 p.built.theme.plot_margin.top -= (h - fits) / 2.0;
1350 }
1351 }
1352 }
1353 }
1354
1355 if mode == AxisCollect::CollectX {
1356 collect_x_domain(cells, &plot_cells, warnings);
1357 }
1358 }
1359}
1360
1361fn collect_x_domain(
1370 cells: &mut [Prepared],
1371 plot_cells: &[(usize, usize)],
1372 warnings: &mut Vec<String>,
1373) {
1374 let mut domains = Vec::with_capacity(plot_cells.len());
1375 for &(_, k) in plot_cells {
1376 let d = match &cells[k] {
1377 Prepared::Plot(p) => match p.built.scales.get(&Aesthetic::X) {
1378 Some(s) if !s.is_discrete() => s.domain(),
1379 _ => None,
1380 },
1381 _ => None,
1382 };
1383 match d {
1384 Some(d) => domains.push(d),
1385 None => {
1386 warnings.push(
1387 "plot_grid: a column's x scale is discrete or untrained; x domain not \
1388 collected"
1389 .to_string(),
1390 );
1391 return;
1392 }
1393 }
1394 }
1395 let lo = domains.iter().map(|d| d.0).fold(f64::INFINITY, f64::min);
1396 let hi = domains
1397 .iter()
1398 .map(|d| d.1)
1399 .fold(f64::NEG_INFINITY, f64::max);
1400 if lo.is_finite() && hi.is_finite() && hi >= lo {
1401 for &(_, k) in plot_cells {
1402 if let Prepared::Plot(p) = &mut cells[k] {
1403 p.built.scales.set_limits(&Aesthetic::X, lo, hi);
1404 }
1405 }
1406 }
1407}
1408
1409struct Measure<'a> {
1411 inner: &'a mut SvgBackend,
1412 pa: Rect,
1413 bbox: Option<(f64, f64, f64, f64)>,
1414}
1415
1416impl<'a> Measure<'a> {
1417 fn new(inner: &'a mut SvgBackend, pa: Rect) -> Self {
1418 Measure {
1419 inner,
1420 pa,
1421 bbox: None,
1422 }
1423 }
1424
1425 fn grow(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
1426 if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
1427 return;
1428 }
1429 self.bbox = Some(match self.bbox {
1430 None => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),
1431 Some((a, b, c, d)) => (
1432 a.min(x0.min(x1)),
1433 b.min(y0.min(y1)),
1434 c.max(x0.max(x1)),
1435 d.max(y0.max(y1)),
1436 ),
1437 });
1438 }
1439
1440 fn extent(&self) -> Option<(f64, f64, f64, f64)> {
1441 self.bbox
1442 }
1443}
1444
1445impl DrawBackend for Measure<'_> {
1446 fn draw_circle(
1447 &mut self,
1448 c: (f64, f64),
1449 r: f64,
1450 style: &PointStyle,
1451 ) -> Result<(), RenderError> {
1452 self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
1453 self.inner.draw_circle(c, r, style)
1454 }
1455 fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
1456 for p in pts {
1457 self.grow(p.0, p.1, p.0, p.1);
1458 }
1459 self.inner.draw_line(pts, style)
1460 }
1461 fn draw_quadratic(
1462 &mut self,
1463 p0: (f64, f64),
1464 ctrl: (f64, f64),
1465 p1: (f64, f64),
1466 style: &LineStyle,
1467 ) -> Result<(), RenderError> {
1468 self.grow(p0.0, p0.1, p0.0, p0.1);
1469 self.grow(ctrl.0, ctrl.1, ctrl.0, ctrl.1);
1470 self.grow(p1.0, p1.1, p1.0, p1.1);
1471 self.inner.draw_quadratic(p0, ctrl, p1, style)
1472 }
1473 fn draw_rect(
1474 &mut self,
1475 a: (f64, f64),
1476 b: (f64, f64),
1477 style: &RectStyle,
1478 ) -> Result<(), RenderError> {
1479 self.grow(a.0, a.1, b.0, b.1);
1480 self.inner.draw_rect(a, b, style)
1481 }
1482 fn draw_text(
1483 &mut self,
1484 text: &str,
1485 pos: (f64, f64),
1486 style: &TextStyle,
1487 ) -> Result<(), RenderError> {
1488 self.draw_text_with_title(text, pos, style, None)
1489 }
1490 fn draw_text_with_title(
1491 &mut self,
1492 text: &str,
1493 pos: (f64, f64),
1494 style: &TextStyle,
1495 title: Option<&str>,
1496 ) -> Result<(), RenderError> {
1497 let w = text.chars().count() as f64 * style.size * 0.6;
1505 let (x0, x1) = match style.anchor {
1506 TextAnchor::Start => (pos.0, pos.0 + w),
1507 TextAnchor::Middle => (pos.0 - w / 2.0, pos.0 + w / 2.0),
1508 TextAnchor::End => (pos.0 - w, pos.0),
1509 };
1510 self.grow(x0, pos.1 - style.size / 2.0, x1, pos.1 + style.size / 2.0);
1511 self.inner.draw_text_with_title(text, pos, style, title)
1512 }
1513 fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
1514 for p in pts {
1515 self.grow(p.0, p.1, p.0, p.1);
1516 }
1517 self.inner.draw_polygon(pts, style)
1518 }
1519 fn draw_shape(&mut self, c: (f64, f64), r: f64, style: &PointStyle) -> Result<(), RenderError> {
1520 self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
1521 self.inner.draw_shape(c, r, style)
1522 }
1523 fn plot_area(&self) -> Rect {
1524 self.pa.clone()
1525 }
1526 fn total_area(&self) -> Rect {
1527 self.inner.total_area()
1528 }
1529 fn set_tooltip(&mut self, t: Option<String>) {
1530 self.inner.set_tooltip(t)
1531 }
1532 fn set_mark_axis(&mut self, k: Option<String>) {
1533 self.inner.set_mark_axis(k)
1534 }
1535 fn set_mark_series(&mut self, s: Option<String>) {
1536 self.inner.set_mark_series(s)
1537 }
1538 fn set_mark_value(&mut self, v: Option<String>) {
1539 self.inner.set_mark_value(v)
1540 }
1541 fn warn(&mut self, m: String) {
1542 self.inner.warn(m)
1543 }
1544 fn set_mark_geom(&mut self, geom: Option<&str>) {
1545 self.inner.set_mark_geom(geom)
1546 }
1547 fn set_mark_part(&mut self, part: Option<&str>) {
1548 self.inner.set_mark_part(part)
1549 }
1550 fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
1551 self.inner.set_mark_attrs(attrs)
1552 }
1553
1554 fn set_mark_class(&mut self, class: Option<String>) {
1555 self.inner.set_mark_class(class)
1556 }
1557
1558 fn set_mark_group(&mut self, group: Option<(String, String)>) {
1559 self.inner.set_mark_group(group)
1560 }
1561
1562 fn set_layer_attrs(&mut self, attrs: Vec<(String, String)>) {
1563 self.inner.set_layer_attrs(attrs)
1564 }
1565 fn wants_formatted_values(&self) -> bool {
1566 self.inner.wants_formatted_values()
1567 }
1568 fn set_mark_fvalue(&mut self, value: Option<String>) {
1569 self.inner.set_mark_fvalue(value)
1570 }
1571 fn set_mark_fx(&mut self, value: Option<String>) {
1572 self.inner.set_mark_fx(value)
1573 }
1574}
1575
1576fn join(op: Op, a: GridCell, b: GridCell) -> PlotGrid {
1579 let flatten = |c: GridCell| -> Vec<GridCell> {
1580 match c {
1581 GridCell::Grid(g) if g.op == Some(op) && g.is_plain() => g.cells,
1582 other => vec![other],
1583 }
1584 };
1585 let mut cells = flatten(a);
1586 cells.extend(flatten(b));
1587 PlotGrid {
1588 cells,
1589 op: Some(op),
1590 ..PlotGrid::default()
1591 }
1592}
1593
1594impl PlotGrid {
1595 fn is_plain(&self) -> bool {
1598 self.ncol.is_none()
1599 && self.nrow.is_none()
1600 && self.widths.is_empty()
1601 && self.heights.is_empty()
1602 && self.title.is_none()
1603 && self.subtitle.is_none()
1604 && self.caption.is_none()
1605 && self.tags.is_none()
1606 && !self.collect_legends
1607 && self.legend_bottom_share.is_none()
1608 && self.axis_collect == AxisCollect::Keep
1609 }
1610}
1611
1612macro_rules! grid_ops {
1613 ($lhs:ty, $rhs:ty) => {
1614 impl BitOr<$rhs> for $lhs {
1616 type Output = PlotGrid;
1617 fn bitor(self, rhs: $rhs) -> PlotGrid {
1618 join(Op::Row, self.into(), rhs.into())
1619 }
1620 }
1621 impl Div<$rhs> for $lhs {
1623 type Output = PlotGrid;
1624 fn div(self, rhs: $rhs) -> PlotGrid {
1625 join(Op::Col, self.into(), rhs.into())
1626 }
1627 }
1628 };
1629}
1630
1631grid_ops!(GGPlot, GGPlot);
1632grid_ops!(GGPlot, PlotGrid);
1633grid_ops!(PlotGrid, GGPlot);
1634grid_ops!(PlotGrid, PlotGrid);
1635grid_ops!(GGPlot, GridElement);
1636grid_ops!(GridElement, GGPlot);
1637grid_ops!(PlotGrid, GridElement);
1638grid_ops!(GridElement, PlotGrid);
1639
1640#[cfg(test)]
1641mod tests {
1642 use super::*;
1643
1644 #[test]
1645 fn tag_sequences() {
1646 let l: Vec<String> = (0..28).map(|i| TagLevels::Lower.tag(i)).collect();
1647 assert_eq!(&l[..3], &["a", "b", "c"]);
1648 assert_eq!(l[25], "z");
1649 assert_eq!(l[26], "aa");
1650 assert_eq!(l[27], "ab");
1651 assert_eq!(TagLevels::Upper.tag(1), "B");
1652 assert_eq!(TagLevels::Numeric.tag(9), "10");
1653 assert_eq!(TagLevels::LowerRoman.tag(3), "iv");
1654 assert_eq!(TagLevels::UpperRoman.tag(13), "XIV");
1655 assert_eq!(TagLevels::Custom(vec!["x".into()]).tag(1), "2");
1656 }
1657
1658 #[test]
1659 fn tracks_are_relative_and_robust() {
1660 let t = tracks(&[2.0, 1.0], 2, 300.0, 0.0);
1661 assert_eq!(t, vec![(0.0, 200.0), (200.0, 100.0)]);
1662 let t = tracks(&[f64::NAN, -1.0], 2, 100.0, 10.0);
1663 assert_eq!(t, vec![(0.0, 45.0), (55.0, 45.0)]);
1664 let t = tracks(&[], 3, 30.0, 100.0);
1665 assert!(t.iter().all(|(p, s)| p.is_finite() && *s == 0.0));
1666 }
1667
1668 #[test]
1672 fn measure_forwards_draw_quadratic_and_mark_metadata_and_grows_its_extent() {
1673 let pa = Rect {
1674 x: 0.0,
1675 y: 0.0,
1676 width: 100.0,
1677 height: 100.0,
1678 };
1679 let mut backend = SvgBackend::new(100, 100, pa.clone());
1680 backend.set_formatted_values(true);
1681 let mut meas = Measure::new(&mut backend, pa);
1682
1683 assert!(meas.extent().is_none());
1684 assert!(meas.wants_formatted_values());
1686 meas.set_mark_geom(Some("line"));
1687 meas.set_mark_part(Some("arrow"));
1688 meas.set_mark_attrs(vec![("data-x".to_string(), "v".to_string())]);
1689 meas.set_mark_fvalue(Some("1,234.5".to_string()));
1690 meas.set_mark_fx(Some("2026-10-12".to_string()));
1691 meas.draw_quadratic(
1692 (10.0, 20.0),
1693 (90.0, 5.0),
1694 (50.0, 80.0),
1695 &LineStyle::default(),
1696 )
1697 .unwrap();
1698 meas.set_mark_geom(None);
1699 meas.set_mark_part(None);
1700 meas.set_mark_attrs(Vec::new());
1701 meas.set_mark_fvalue(None);
1702 meas.set_mark_fx(None);
1703
1704 let (x0, y0, x1, y1) = meas.extent().expect("extent after drawing");
1705 assert_eq!((x0, y0, x1, y1), (10.0, 5.0, 90.0, 80.0));
1706
1707 let svg = backend.finish();
1711 assert!(svg.contains("data-part=\"arrow\""));
1712 assert!(svg.contains("data-x=\"v\""));
1713 assert!(svg.contains("gg-arrow"));
1714 assert!(svg.contains("data-fvalue=\"1,234.5\""));
1715 assert!(svg.contains("data-fx=\"2026-10-12\""));
1716 }
1717
1718 #[test]
1729 fn measure_forwards_the_title_to_its_inner_backend() {
1730 let pa = Rect {
1731 x: 0.0,
1732 y: 0.0,
1733 width: 100.0,
1734 height: 100.0,
1735 };
1736 let mut backend = SvgBackend::new(100, 100, pa.clone());
1737 let mut meas = Measure::new(&mut backend, pa);
1738 meas.draw_text_with_title(
1739 "Grp…",
1740 (10.0, 20.0),
1741 &TextStyle {
1742 size: 12.0,
1743 anchor: TextAnchor::Start,
1744 ..Default::default()
1745 },
1746 Some("Group 01"),
1747 )
1748 .unwrap();
1749 let svg = backend.finish();
1750 assert!(
1751 svg.contains("<title>Group 01</title>"),
1752 "the full label should reach the inner backend's <title>: {svg}"
1753 );
1754 }
1755}