Skip to main content

ggplot_rs/
compose.rs

1//! Plot composition for the native SVG path — a patchwork-style [`PlotGrid`].
2//!
3//! ```
4//! use ggplot_rs::prelude::*;
5//! use ggplot_rs::data::Value;
6//! let data = || vec![
7//!     ("x".to_string(), (0..10).map(|i| Value::Float(i as f64)).collect::<Vec<_>>()),
8//!     ("y".to_string(), (0..10).map(|i| Value::Float((i * i) as f64)).collect::<Vec<_>>()),
9//! ];
10//! let p = || GGPlot::new(data()).aes(Aes::new().x("x").y("y"));
11//! // a | b puts plots side by side, a / b stacks them; `/` binds tighter.
12//! let svg = ((p().geom_point() | p().geom_line()) / p().geom_col())
13//!     .title("Diagnostics")
14//!     .tag_levels(TagLevels::Lower)
15//!     .render_svg_native_with_size(900, 700)
16//!     .unwrap();
17//! assert!(svg.contains("data-panel=\"a\"") && svg.contains("data-panel=\"c\""));
18//! ```
19//!
20//! Every sub-plot is rendered as a positioned nested `<svg>` fragment (see
21//! [`GGPlot::render_svg_native_at`]) that keeps all of its host attributes
22//! (`data-plot`, `data-domain`, marks' `data-x`/`data-series`/`data-value`)
23//! and additionally carries `data-panel="<tag or index>"`. A host maps a
24//! pointer to data coordinates by first finding the enclosing sub-`<svg>`.
25
26use std::ops::{BitOr, Div};
27
28use crate::guide::legend;
29use crate::plot::{GGError, GGPlot, RenderMeta};
30use crate::render::backend::{
31    DrawBackend, LineStyle, PointStyle, RectStyle, TextAnchor, TextStyle,
32};
33use crate::render::renderer::PlotRenderer;
34use crate::render::svg_backend::{root_data_attrs, SvgBackend};
35use crate::render::{Rect, RenderError};
36use crate::theme::{LegendPosition, Theme};
37
38/// Panel-tag sequence (patchwork's `tag_levels`).
39#[derive(Clone, Debug, PartialEq)]
40pub enum TagLevels {
41    /// `a`, `b`, … `z`, `aa`, …
42    Lower,
43    /// `A`, `B`, …
44    Upper,
45    /// `1`, `2`, …
46    Numeric,
47    /// `i`, `ii`, `iii`, …
48    LowerRoman,
49    /// `I`, `II`, …
50    UpperRoman,
51    /// Explicit tags; panels beyond the list are numbered.
52    Custom(Vec<String>),
53}
54
55impl TagLevels {
56    /// The tag of the `i`-th (0-based) panel.
57    pub fn tag(&self, i: usize) -> String {
58        match self {
59            TagLevels::Lower => alpha(i, b'a'),
60            TagLevels::Upper => alpha(i, b'A'),
61            TagLevels::Numeric => (i + 1).to_string(),
62            TagLevels::LowerRoman => roman(i + 1).to_lowercase(),
63            TagLevels::UpperRoman => roman(i + 1),
64            TagLevels::Custom(v) => v.get(i).cloned().unwrap_or_else(|| (i + 1).to_string()),
65        }
66    }
67}
68
69fn alpha(mut i: usize, base: u8) -> String {
70    // Bijective base-26: a..z, aa..az, ba..
71    let mut out = Vec::new();
72    loop {
73        out.push(base + (i % 26) as u8);
74        if i < 26 {
75            break;
76        }
77        i = i / 26 - 1;
78    }
79    out.reverse();
80    String::from_utf8(out).unwrap_or_default()
81}
82
83fn roman(mut n: usize) -> String {
84    if n >= 4000 {
85        return n.to_string();
86    }
87    const T: [(usize, &str); 13] = [
88        (1000, "M"),
89        (900, "CM"),
90        (500, "D"),
91        (400, "CD"),
92        (100, "C"),
93        (90, "XC"),
94        (50, "L"),
95        (40, "XL"),
96        (10, "X"),
97        (9, "IX"),
98        (5, "V"),
99        (4, "IV"),
100        (1, "I"),
101    ];
102    let mut s = String::new();
103    for (v, r) in T {
104        while n >= v {
105            s.push_str(r);
106            n -= v;
107        }
108    }
109    s
110}
111
112/// Where a [`PlotGrid`] puts its collected legends.
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114pub enum GridLegendPosition {
115    /// A column to the right of the grid (default).
116    #[default]
117    Right,
118    /// A row below the grid.
119    Bottom,
120}
121
122/// How a grid was built by the `|` / `/` operators, so chains flatten
123/// (`a | b | c` is one row) while mixed operators nest.
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125enum Op {
126    Row,
127    Col,
128}
129
130/// One grid cell.
131pub enum GridCell {
132    Plot(Box<GGPlot>),
133    Grid(Box<PlotGrid>),
134    /// An empty cell (patchwork's `plot_spacer()`).
135    Spacer,
136}
137
138impl From<GGPlot> for GridCell {
139    fn from(p: GGPlot) -> Self {
140        GridCell::Plot(Box::new(p))
141    }
142}
143
144impl From<PlotGrid> for GridCell {
145    fn from(g: PlotGrid) -> Self {
146        GridCell::Grid(Box::new(g))
147    }
148}
149
150/// A patchwork-style arrangement of plots rendered into **one** SVG on the
151/// plotters-free native path.
152///
153/// - cells fill row-major (or column-major with [`byrow(false)`](Self::byrow))
154///   across [`ncol`](Self::ncol) / [`nrow`](Self::nrow) (default: a near-square
155///   grid; `a | b` is one row, `a / b` one column);
156/// - [`widths`](Self::widths) / [`heights`](Self::heights) set relative
157///   column widths / row heights;
158/// - [`title`](Self::title) / [`subtitle`](Self::subtitle) /
159///   [`caption`](Self::caption) annotate the whole grid;
160/// - [`tag_levels`](Self::tag_levels) tags panels `a, b, …` (`A`, `1`, roman,
161///   custom) in reading order, through nested grids;
162/// - [`collect_legends`](Self::collect_legends) removes the sub-plots'
163///   legends and draws each distinct legend once beside the grid (identical
164///   legends are de-duplicated).
165///
166/// Nested grids keep their own layout and annotations; tagging and legend
167/// collection are controlled by the outermost grid.
168pub struct PlotGrid {
169    cells: Vec<GridCell>,
170    ncol: Option<usize>,
171    nrow: Option<usize>,
172    byrow: bool,
173    widths: Vec<f64>,
174    heights: Vec<f64>,
175    title: Option<String>,
176    subtitle: Option<String>,
177    caption: Option<String>,
178    tags: Option<TagLevels>,
179    tag_prefix: String,
180    tag_suffix: String,
181    collect_legends: bool,
182    legend_position: GridLegendPosition,
183    spacing: f64,
184    theme: Theme,
185    op: Option<Op>,
186    /// Extra root `<svg>` attributes requested via [`PlotGrid::svg_attr`]/
187    /// [`PlotGrid::svg_attrs`], validated and applied at render time.
188    root_attrs: Vec<(String, String)>,
189    /// Whether to emit a `data-warnings` JSON array of the panels'
190    /// combined build warnings on the root `<svg>`
191    /// ([`PlotGrid::svg_warnings_attr`]). Default off; byte-identical
192    /// output when off.
193    svg_warnings_attr: bool,
194}
195
196impl Default for PlotGrid {
197    fn default() -> Self {
198        PlotGrid {
199            cells: Vec::new(),
200            ncol: None,
201            nrow: None,
202            byrow: true,
203            widths: Vec::new(),
204            heights: Vec::new(),
205            title: None,
206            subtitle: None,
207            caption: None,
208            tags: None,
209            tag_prefix: String::new(),
210            tag_suffix: String::new(),
211            collect_legends: false,
212            legend_position: GridLegendPosition::Right,
213            spacing: 0.0,
214            theme: Theme::default(),
215            op: None,
216            root_attrs: Vec::new(),
217            svg_warnings_attr: false,
218        }
219    }
220}
221
222/// Upper bound on grid columns/rows — a guard against absurd user input.
223const MAX_TRACKS: usize = 10_000;
224
225impl PlotGrid {
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Append a plot (or a nested grid) as the next cell.
231    #[allow(clippy::should_implement_trait)]
232    pub fn add(mut self, cell: impl Into<GridCell>) -> Self {
233        self.cells.push(cell.into());
234        self
235    }
236
237    /// Append several plots.
238    pub fn add_all(mut self, plots: impl IntoIterator<Item = GGPlot>) -> Self {
239        self.cells.extend(plots.into_iter().map(GridCell::from));
240        self
241    }
242
243    /// Append an empty cell (patchwork's `plot_spacer()`).
244    pub fn add_spacer(mut self) -> Self {
245        self.cells.push(GridCell::Spacer);
246        self
247    }
248
249    /// Number of columns.
250    pub fn ncol(mut self, n: usize) -> Self {
251        self.ncol = (n > 0).then_some(n.min(MAX_TRACKS));
252        self
253    }
254
255    /// Number of rows.
256    pub fn nrow(mut self, n: usize) -> Self {
257        self.nrow = (n > 0).then_some(n.min(MAX_TRACKS));
258        self
259    }
260
261    /// Fill cells row by row (default) or column by column.
262    pub fn byrow(mut self, byrow: bool) -> Self {
263        self.byrow = byrow;
264        self
265    }
266
267    /// Relative column widths (recycled; non-positive / non-finite = 1).
268    pub fn widths(mut self, widths: &[f64]) -> Self {
269        self.widths = widths.to_vec();
270        self
271    }
272
273    /// Relative row heights (recycled; non-positive / non-finite = 1).
274    pub fn heights(mut self, heights: &[f64]) -> Self {
275        self.heights = heights.to_vec();
276        self
277    }
278
279    /// Title over the whole grid.
280    pub fn title(mut self, title: &str) -> Self {
281        self.title = Some(title.to_string());
282        self
283    }
284
285    /// Subtitle over the whole grid.
286    pub fn subtitle(mut self, subtitle: &str) -> Self {
287        self.subtitle = Some(subtitle.to_string());
288        self
289    }
290
291    /// Caption under the whole grid.
292    pub fn caption(mut self, caption: &str) -> Self {
293        self.caption = Some(caption.to_string());
294        self
295    }
296
297    /// Tag the panels (`labs(tag)` of each sub-plot is replaced).
298    pub fn tag_levels(mut self, levels: TagLevels) -> Self {
299        self.tags = Some(levels);
300        self
301    }
302
303    /// Text around each tag, e.g. `("(", ")")` for `(a)`.
304    pub fn tag_affixes(mut self, prefix: &str, suffix: &str) -> Self {
305        self.tag_prefix = prefix.to_string();
306        self.tag_suffix = suffix.to_string();
307        self
308    }
309
310    /// Collect the sub-plots' legends into one strip (identical legends are
311    /// drawn once). Sub-plots with an inside or hidden legend keep it.
312    pub fn collect_legends(mut self, collect: bool) -> Self {
313        self.collect_legends = collect;
314        self
315    }
316
317    /// Where collected legends go.
318    pub fn legend_position(mut self, pos: GridLegendPosition) -> Self {
319        self.legend_position = pos;
320        self
321    }
322
323    /// Gap between cells, in px (default 0).
324    pub fn spacing(mut self, px: f64) -> Self {
325        self.spacing = if px.is_finite() { px.max(0.0) } else { 0.0 };
326        self
327    }
328
329    /// Theme for the grid's own title/subtitle/caption and the collected
330    /// legends' text.
331    pub fn theme(mut self, theme: Theme) -> Self {
332        self.theme = theme;
333        self
334    }
335
336    /// Add one extra attribute to the root `<svg>` element — see
337    /// [`GGPlot::svg_attr`](crate::plot::GGPlot::svg_attr), same allow-list
338    /// (`data-*`, `aria-*`, `role`, `id`, `class`, `lang`) and the same
339    /// "ignored with a warning" rule for anything else or a collision with
340    /// what ggplot-rs already writes (`data-grid`, or `data-warnings` when
341    /// [`svg_warnings_attr`](Self::svg_warnings_attr) is also on).
342    pub fn svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
343        self.root_attrs.push((name.into(), value.into()));
344        self
345    }
346
347    /// [`svg_attr`](Self::svg_attr) for several attributes at once.
348    pub fn svg_attrs(mut self, attrs: impl IntoIterator<Item = (String, String)>) -> Self {
349        self.root_attrs.extend(attrs);
350        self
351    }
352
353    /// Emit a `data-warnings` attribute on the root `<svg>` — a JSON array
354    /// of every panel's combined build warnings, `[]` when there are none.
355    /// Default off, and omitted entirely when off.
356    pub fn svg_warnings_attr(mut self, on: bool) -> Self {
357        self.svg_warnings_attr = on;
358        self
359    }
360
361    /// Number of cells (plots, nested grids and spacers).
362    pub fn len(&self) -> usize {
363        self.cells.len()
364    }
365
366    /// Whether the grid has no cells.
367    pub fn is_empty(&self) -> bool {
368        self.cells.is_empty()
369    }
370
371    /// Render at 800×600.
372    pub fn render_svg_native(self) -> Result<String, GGError> {
373        self.render_svg_native_with_size(800, 600)
374    }
375
376    /// Render the grid to one standalone SVG document of `w`×`h` px. The root
377    /// carries `data-grid="<rows> <cols>"` (and no `data-plot`: each sub-`<svg>`
378    /// has its own).
379    pub fn render_svg_native_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
380        Ok(self.render_page(w, h)?.0.finish())
381    }
382
383    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
384    /// also returning every sub-plot's warnings, prefixed `panel <tag>: `.
385    pub fn render_svg_native_with_warnings(
386        self,
387        w: u32,
388        h: u32,
389    ) -> Result<(String, Vec<String>), GGError> {
390        let (page, warnings) = self.render_page(w, h)?;
391        Ok((page.finish(), warnings))
392    }
393
394    /// Render as a nested `<svg>` fragment positioned at `(x, y)` — for
395    /// composing the grid into a larger SVG (cf. [`GGPlot::render_svg_native_at`]).
396    pub fn render_svg_native_at(self, x: f64, y: f64, w: u32, h: u32) -> Result<String, GGError> {
397        Ok(self.render_page(w, h)?.0.finish_fragment(x, y))
398    }
399
400    fn render_page(self, w: u32, h: u32) -> Result<(SvgBackend, Vec<String>), GGError> {
401        let (w, h) = (w.max(1), h.max(1));
402        let mut theme = self.theme.clone();
403        theme.resolve_inheritance();
404        let collect = self.collect_legends;
405        let legend_pos = self.legend_position;
406        let root_attrs = self.root_attrs.clone();
407        let svg_warnings_attr = self.svg_warnings_attr;
408
409        // Phase 1 (size-independent): build every plot, assign tags and
410        // capture legends to collect.
411        let mut ctx = PrepCtx {
412            tags: self
413                .tags
414                .clone()
415                .map(|t| (t, self.tag_prefix.clone(), self.tag_suffix.clone())),
416            next_tag: 0,
417            next_index: 0,
418            collect: collect.then_some(legend_pos),
419            legends: Vec::new(),
420            page_w: w as f64,
421            page_h: h as f64,
422            legend_warnings: Vec::new(),
423        };
424        let prepared = prepare_grid(self, &mut ctx)?;
425        let legends = ctx.legends;
426        let legend_warnings = ctx.legend_warnings;
427
428        // Phase 2: page layout.
429        let full = Rect {
430            x: 0.0,
431            y: 0.0,
432            width: w as f64,
433            height: h as f64,
434        };
435        let mut page = SvgBackend::new(w, h, full.clone());
436        page.without_plot_attr();
437        let (rows, cols) = prepared.dims();
438        let data_grid_attr = ("data-grid".to_string(), format!("{rows} {cols}"));
439        page.set_root_attrs(vec![data_grid_attr.clone()]);
440        if let Some(fill) = theme
441            .plot_background
442            .fill
443            .filter(|_| theme.plot_background.visible)
444        {
445            page.draw_rect(
446                (0.0, 0.0),
447                (w as f64, h as f64),
448                &RectStyle {
449                    fill: Some(fill),
450                    stroke: None,
451                    stroke_width: 0.0,
452                    alpha: 1.0,
453                    clip: false,
454                },
455            )
456            .map_err(GGError::Render)?;
457        }
458
459        let mut body = full;
460        if !legends.is_empty() {
461            let gap = theme.legend_spacing.max(0.0) * 2.0;
462            let pad = 8.0;
463            match legend_pos {
464                GridLegendPosition::Right => {
465                    let strip_w = legends.iter().map(|l| l.w).fold(0.0, f64::max) + 2.0 * pad;
466                    let strip_w = strip_w.min(body.width * 0.4);
467                    body.width -= strip_w;
468                    let total_h =
469                        legends.iter().map(|l| l.h).sum::<f64>() + gap * (legends.len() - 1) as f64;
470                    let mut y = (body.y + (body.height - total_h) / 2.0).max(body.y + pad);
471                    for l in &legends {
472                        place_legend(&mut page, l, body.x + body.width + pad, y);
473                        y += l.h + gap;
474                    }
475                }
476                GridLegendPosition::Bottom => {
477                    let strip_h = legends.iter().map(|l| l.h).fold(0.0, f64::max) + 2.0 * pad;
478                    let strip_h = strip_h.min(body.height * 0.4);
479                    body.height -= strip_h;
480                    let total_w =
481                        legends.iter().map(|l| l.w).sum::<f64>() + gap * (legends.len() - 1) as f64;
482                    let mut x = (body.x + (body.width - total_w) / 2.0).max(body.x + pad);
483                    for l in &legends {
484                        place_legend(&mut page, l, x, body.y + body.height + pad);
485                        x += l.w + gap;
486                    }
487                }
488            }
489        }
490
491        // Phase 3: render cells into their rects.
492        let mut warnings = legend_warnings;
493        render_prepared(prepared, &body, &mut page, &mut warnings)?;
494
495        let mut reserved = vec![data_grid_attr];
496        if svg_warnings_attr {
497            reserved.push(("data-warnings".to_string(), String::new()));
498        }
499        let extra = crate::render::svg_backend::validate_root_attrs(
500            &root_attrs,
501            &reserved,
502            "grid",
503            &mut warnings,
504        );
505        page.extend_root_attrs(extra);
506        if svg_warnings_attr {
507            page.extend_root_attrs(vec![(
508                "data-warnings".to_string(),
509                crate::render::svg_backend::warnings_json(&warnings),
510            )]);
511        }
512        Ok((page, warnings))
513    }
514}
515
516/// A collected legend: rendered markup (also its identity) and extent.
517struct Legend {
518    body: String,
519    x0: f64,
520    y0: f64,
521    w: f64,
522    h: f64,
523}
524
525fn place_legend(page: &mut SvgBackend, l: &Legend, x: f64, y: f64) {
526    let (tx, ty) = (x - l.x0, y - l.y0);
527    if tx.is_finite() && ty.is_finite() {
528        page.push_raw(&format!(
529            "<g class=\"legend\" transform=\"translate({tx:.2} {ty:.2})\">{}</g>",
530            l.body
531        ));
532    }
533}
534
535struct PrepCtx {
536    tags: Option<(TagLevels, String, String)>,
537    next_tag: usize,
538    next_index: usize,
539    collect: Option<GridLegendPosition>,
540    legends: Vec<Legend>,
541    /// The grid's overall page size (sipemu/ggplot-rs#155): a collected
542    /// legend's available width/height for its own B3-2 wrap/grid plan,
543    /// since a `PlotGrid`'s legend strip has no other natural bound —
544    /// it's sized by whatever the legend needs, so the legend instead
545    /// wraps to fit within the page itself.
546    page_w: f64,
547    page_h: f64,
548    /// Warnings `capture_legend`'s `plan_all_legends` call produces for a
549    /// collected legend (#170 / review #167 finding 6) — previously
550    /// discarded; merged into `render_page`'s own `warnings` so they
551    /// reach `render_svg_native_with_warnings` / `data-warnings` like
552    /// every other warning.
553    legend_warnings: Vec<String>,
554}
555
556/// A built sub-plot awaiting its cell size.
557struct PreparedPlot {
558    built: crate::build::BuiltPlot,
559    meta: RenderMeta,
560    panel: String,
561    /// The cell's own `GGPlot::svg_formatted_values` setting (review
562    /// PR#141 finding 6, LOW): `build_for_render` consumes the `GGPlot`
563    /// and discards this field (it only matters at render time, and
564    /// `PlotGrid` builds its cells independently of
565    /// `GGPlot::render_native`, the only place that previously read it),
566    /// so it has to be captured here and carried to the cell's own
567    /// backend in `render_prepared`.
568    svg_formatted_values: bool,
569}
570
571enum Prepared {
572    Plot(Box<PreparedPlot>),
573    Grid(Box<PreparedGrid>),
574    Spacer,
575}
576
577struct PreparedGrid {
578    cells: Vec<Prepared>,
579    ncol: usize,
580    nrow: usize,
581    byrow: bool,
582    widths: Vec<f64>,
583    heights: Vec<f64>,
584    title: Option<String>,
585    subtitle: Option<String>,
586    caption: Option<String>,
587    spacing: f64,
588    theme: Theme,
589}
590
591impl Prepared {
592    fn dims(&self) -> (usize, usize) {
593        match self {
594            Prepared::Grid(g) => (g.nrow, g.ncol),
595            _ => (1, 1),
596        }
597    }
598}
599
600fn prepare_grid(grid: PlotGrid, ctx: &mut PrepCtx) -> Result<Prepared, GGError> {
601    let n = grid.cells.len();
602    let (ncol, nrow) = match (grid.ncol, grid.nrow, grid.op) {
603        (Some(c), Some(r), _) => {
604            // Grow rows if the cells don't fit.
605            (c, r.max(n.div_ceil(c)))
606        }
607        (Some(c), None, _) => (c, n.div_ceil(c).max(1)),
608        (None, Some(r), _) => (n.div_ceil(r).max(1), r),
609        (None, None, Some(Op::Row)) => (n.max(1), 1),
610        (None, None, Some(Op::Col)) => (1, n.max(1)),
611        (None, None, None) => {
612            let c = ((n as f64).sqrt().ceil() as usize).max(1);
613            (c, n.div_ceil(c).max(1))
614        }
615    };
616    let mut theme = grid.theme;
617    theme.resolve_inheritance();
618    let mut cells = Vec::with_capacity(n);
619    for cell in grid.cells {
620        cells.push(match cell {
621            GridCell::Spacer => Prepared::Spacer,
622            GridCell::Grid(g) => prepare_grid(*g, ctx)?,
623            GridCell::Plot(p) => Prepared::Plot(Box::new(prepare_plot(*p, ctx)?)),
624        });
625    }
626    Ok(Prepared::Grid(Box::new(PreparedGrid {
627        cells,
628        ncol,
629        nrow,
630        byrow: grid.byrow,
631        widths: grid.widths,
632        heights: grid.heights,
633        title: grid.title,
634        subtitle: grid.subtitle,
635        caption: grid.caption,
636        spacing: grid.spacing,
637        theme,
638    })))
639}
640
641fn prepare_plot(mut plot: GGPlot, ctx: &mut PrepCtx) -> Result<PreparedPlot, GGError> {
642    ctx.next_index += 1;
643    if let Some((levels, pre, suf)) = &ctx.tags {
644        plot.labels.tag = Some(format!("{pre}{}{suf}", levels.tag(ctx.next_tag)));
645        ctx.next_tag += 1;
646    }
647    let panel = plot
648        .labels
649        .tag
650        .clone()
651        .unwrap_or_else(|| ctx.next_index.to_string());
652
653    // Collect an outside legend: suppress it in the plot, draw it separately.
654    let original = plot.theme.legend_position.clone();
655    let collect = ctx.collect.filter(|_| {
656        matches!(
657            original,
658            LegendPosition::Right
659                | LegendPosition::Left
660                | LegendPosition::Top
661                | LegendPosition::Bottom
662        )
663    });
664    if collect.is_some() {
665        plot.theme.legend_position = LegendPosition::None;
666    }
667    let svg_formatted_values = plot.svg_formatted_values;
668    let (built, mut meta) = plot.build_for_render()?;
669    if let Some(pos) = collect {
670        meta.has_legend = false;
671        if let Some(l) = capture_legend(
672            &built,
673            pos,
674            ctx.page_w,
675            ctx.page_h,
676            &mut ctx.legend_warnings,
677        )
678        .map_err(GGError::Render)?
679        {
680            if !ctx.legends.iter().any(|o| o.body == l.body) {
681                ctx.legends.push(l);
682            }
683        }
684    }
685    Ok(PreparedPlot {
686        built,
687        meta,
688        panel,
689        svg_formatted_values,
690    })
691}
692
693/// Draw a built plot's legend in isolation, measuring its extent.
694///
695/// `page_w`/`page_h` are the grid's overall page size (sipemu/
696/// ggplot-rs#155): a collected legend has no natural width/height bound
697/// of its own — the strip that holds it is sized by whatever the legend
698/// needs — so a discrete guide wraps/grids here against the page itself,
699/// the same way a single plot's legend wraps against its own canvas.
700fn capture_legend(
701    built: &crate::build::BuiltPlot,
702    pos: GridLegendPosition,
703    page_w: f64,
704    page_h: f64,
705    // Review #167 finding 6 (#170): the plan's own `guide_legend:
706    // nrow(0)/ncol(0) is invalid`-style warnings used to go into a
707    // `legend_warnings` local that nothing ever read — this path dropped
708    // them silently. The caller threads its own warnings vec through so
709    // they land in the grid's `render_svg_native_with_warnings` /
710    // `data-warnings`, the same place a single plot's own legend
711    // warnings go (`GGPlot::layout_built` → `built.warnings`).
712    warnings: &mut Vec<String>,
713) -> Result<Option<Legend>, RenderError> {
714    let mut theme = built.theme.clone();
715    let m = theme.legend_margin.clone();
716    // Fake a zero-size panel so the legend's origin lands at (0, 0).
717    let (pa, lp, is_horizontal) = match pos {
718        GridLegendPosition::Right => (
719            Rect {
720                x: -m.left,
721                y: -m.top,
722                width: 0.0,
723                height: 0.0,
724            },
725            LegendPosition::Right,
726            false,
727        ),
728        GridLegendPosition::Bottom => (
729            Rect {
730                x: -m.left,
731                y: -(m.top + 30.0),
732                width: 0.0,
733                height: 0.0,
734            },
735            LegendPosition::Bottom,
736            true,
737        ),
738    };
739    theme.legend_position = lp;
740    let mut svg = SvgBackend::new(1, 1, pa.clone());
741    let mut meas = Measure::new(&mut svg, pa.clone());
742    let avail_width = (page_w + m.left).max(0.0);
743    let avail_height = (page_h + m.top).max(0.0);
744    let legend_plans = legend::plan_all_legends(
745        &built.scales,
746        &built.guide_legend,
747        &theme,
748        &built.suppressed_aes,
749        avail_width,
750        avail_height,
751        is_horizontal,
752        warnings,
753    );
754    legend::draw_legend_planned(
755        &built.scales,
756        &theme,
757        &pa,
758        // Review #167 finding 1: step by the same `avail_width` share
759        // `plan_all_legends` just planned against, not `pa.width` (always
760        // 0.0 — `pa` is a fake zero-size panel so the legend's origin
761        // lands at (0, 0)). Using `pa.width` made every discrete guide
762        // after the first advance by zero, so two collected guides (e.g.
763        // `colour` and `shape`) were drawn on top of each other.
764        avail_width,
765        &mut meas,
766        &built.guide_legend,
767        &built.suppressed_aes,
768        &legend_plans,
769    )?;
770    let ext = meas.extent();
771    if svg.body().is_empty() {
772        return Ok(None);
773    }
774    let (x0, y0, x1, y1) = match ext {
775        Some(e) => e,
776        None => return Ok(None),
777    };
778    Ok(Some(Legend {
779        body: svg.body().to_string(),
780        x0,
781        y0,
782        w: (x1 - x0).max(0.0),
783        h: (y1 - y0).max(0.0),
784    }))
785}
786
787/// Normalised track sizes: `n` relative weights (recycled, invalid → 1).
788fn tracks(weights: &[f64], n: usize, total: f64, gap: f64) -> Vec<(f64, f64)> {
789    let ws: Vec<f64> = (0..n)
790        .map(|i| {
791            if weights.is_empty() {
792                1.0
793            } else {
794                let w = weights[i % weights.len()];
795                if w.is_finite() && w > 0.0 {
796                    w
797                } else {
798                    1.0
799                }
800            }
801        })
802        .collect();
803    let sum: f64 = ws.iter().sum();
804    let avail = (total - gap * n.saturating_sub(1) as f64).max(0.0);
805    let mut pos = 0.0;
806    ws.iter()
807        .map(|w| {
808            let size = avail * w / sum;
809            let t = (pos, size);
810            pos += size + gap;
811            t
812        })
813        .collect()
814}
815
816fn text_style(el: &crate::theme::elements::ElementText, anchor: TextAnchor) -> TextStyle {
817    TextStyle {
818        color: el.color,
819        size: el.size,
820        anchor,
821        angle: 0.0,
822        family: (!el.family.is_empty()).then(|| el.family.clone()),
823        face: el.face,
824    }
825}
826
827fn hjust_x(el: &crate::theme::elements::ElementText, x0: f64, x1: f64) -> (f64, TextAnchor) {
828    let hj = if el.hjust.is_finite() {
829        el.hjust.clamp(0.0, 1.0)
830    } else {
831        0.0
832    };
833    let anchor = if hj <= 0.02 {
834        TextAnchor::Start
835    } else if hj >= 0.98 {
836        TextAnchor::End
837    } else {
838        TextAnchor::Middle
839    };
840    (x0 + hj * (x1 - x0), anchor)
841}
842
843fn render_prepared(
844    prepared: Prepared,
845    area: &Rect,
846    page: &mut SvgBackend,
847    warnings: &mut Vec<String>,
848) -> Result<(), GGError> {
849    match prepared {
850        Prepared::Spacer => Ok(()),
851        Prepared::Plot(p) => {
852            let PreparedPlot {
853                mut built,
854                meta,
855                panel,
856                svg_formatted_values,
857            } = *p;
858            let (cw, ch) = (
859                area.width.floor().max(1.0) as u32,
860                area.height.floor().max(1.0) as u32,
861            );
862            let layout = GGPlot::layout_built(&mut built, &meta, cw, ch);
863            let mut backend = SvgBackend::new(cw, ch, layout.plot_area.clone());
864            backend.set_formatted_values(svg_formatted_values);
865            let mut attrs = root_data_attrs(&built);
866            attrs.push(("data-panel".into(), panel.clone()));
867            backend.set_root_attrs(attrs);
868            PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
869            warnings.extend(
870                built
871                    .warnings
872                    .iter()
873                    .cloned()
874                    .chain(backend.take_warnings())
875                    .map(|w| format!("panel {panel}: {w}")),
876            );
877            page.push_raw(&backend.finish_fragment(area.x, area.y));
878            Ok(())
879        }
880        Prepared::Grid(g) => {
881            let g = *g;
882            let theme = &g.theme;
883            let m = &theme.plot_margin;
884            // Header / footer bands.
885            let mut top = area.y;
886            let (x0, x1) = (area.x + m.left, area.x + area.width - m.right);
887            if let Some(t) = &g.title {
888                top += m.top;
889                let (tx, anchor) = hjust_x(&theme.title, x0, x1);
890                page.draw_text(
891                    t,
892                    (tx, top + theme.title.size * 0.8),
893                    &text_style(&theme.title, anchor),
894                )
895                .map_err(GGError::Render)?;
896                top += theme.title.size * 1.6;
897            }
898            if let Some(s) = &g.subtitle {
899                if g.title.is_none() {
900                    top += m.top;
901                }
902                let (sx, anchor) = hjust_x(&theme.subtitle, x0, x1);
903                page.draw_text(
904                    s,
905                    (sx, top + theme.subtitle.size * 0.7),
906                    &text_style(&theme.subtitle, anchor),
907                )
908                .map_err(GGError::Render)?;
909                top += theme.subtitle.size * 1.5;
910            }
911            let mut bottom = area.y + area.height;
912            if let Some(c) = &g.caption {
913                let (cx, anchor) = hjust_x(&theme.caption, x0, x1);
914                bottom -= theme.caption.size * 1.8;
915                page.draw_text(
916                    c,
917                    (cx, bottom + theme.caption.size * 0.9),
918                    &text_style(&theme.caption, anchor),
919                )
920                .map_err(GGError::Render)?;
921            }
922            let body_h = (bottom - top).max(1.0);
923            let cols = tracks(&g.widths, g.ncol, area.width, g.spacing);
924            let rows = tracks(&g.heights, g.nrow, body_h, g.spacing);
925            for (k, cell) in g.cells.into_iter().enumerate() {
926                let (r, c) = if g.byrow {
927                    (k / g.ncol, k % g.ncol)
928                } else {
929                    (k % g.nrow, k / g.nrow)
930                };
931                if r >= g.nrow || c >= g.ncol {
932                    // More cells than nrow × ncol: the rest do not fit.
933                    warnings.push(format!(
934                        "plot_grid: cell {} does not fit a {}×{} grid; skipped",
935                        k + 1,
936                        g.nrow,
937                        g.ncol
938                    ));
939                    continue;
940                }
941                let rect = Rect {
942                    x: area.x + cols[c].0,
943                    y: top + rows[r].0,
944                    width: cols[c].1,
945                    height: rows[r].1,
946                };
947                render_prepared(cell, &rect, page, warnings)?;
948            }
949            Ok(())
950        }
951    }
952}
953
954/// A backend wrapper that records the bounding box of everything drawn.
955struct Measure<'a> {
956    inner: &'a mut SvgBackend,
957    pa: Rect,
958    bbox: Option<(f64, f64, f64, f64)>,
959}
960
961impl<'a> Measure<'a> {
962    fn new(inner: &'a mut SvgBackend, pa: Rect) -> Self {
963        Measure {
964            inner,
965            pa,
966            bbox: None,
967        }
968    }
969
970    fn grow(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
971        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
972            return;
973        }
974        self.bbox = Some(match self.bbox {
975            None => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),
976            Some((a, b, c, d)) => (
977                a.min(x0.min(x1)),
978                b.min(y0.min(y1)),
979                c.max(x0.max(x1)),
980                d.max(y0.max(y1)),
981            ),
982        });
983    }
984
985    fn extent(&self) -> Option<(f64, f64, f64, f64)> {
986        self.bbox
987    }
988}
989
990impl DrawBackend for Measure<'_> {
991    fn draw_circle(
992        &mut self,
993        c: (f64, f64),
994        r: f64,
995        style: &PointStyle,
996    ) -> Result<(), RenderError> {
997        self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
998        self.inner.draw_circle(c, r, style)
999    }
1000    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
1001        for p in pts {
1002            self.grow(p.0, p.1, p.0, p.1);
1003        }
1004        self.inner.draw_line(pts, style)
1005    }
1006    fn draw_quadratic(
1007        &mut self,
1008        p0: (f64, f64),
1009        ctrl: (f64, f64),
1010        p1: (f64, f64),
1011        style: &LineStyle,
1012    ) -> Result<(), RenderError> {
1013        self.grow(p0.0, p0.1, p0.0, p0.1);
1014        self.grow(ctrl.0, ctrl.1, ctrl.0, ctrl.1);
1015        self.grow(p1.0, p1.1, p1.0, p1.1);
1016        self.inner.draw_quadratic(p0, ctrl, p1, style)
1017    }
1018    fn draw_rect(
1019        &mut self,
1020        a: (f64, f64),
1021        b: (f64, f64),
1022        style: &RectStyle,
1023    ) -> Result<(), RenderError> {
1024        self.grow(a.0, a.1, b.0, b.1);
1025        self.inner.draw_rect(a, b, style)
1026    }
1027    fn draw_text(
1028        &mut self,
1029        text: &str,
1030        pos: (f64, f64),
1031        style: &TextStyle,
1032    ) -> Result<(), RenderError> {
1033        self.draw_text_with_title(text, pos, style, None)
1034    }
1035    fn draw_text_with_title(
1036        &mut self,
1037        text: &str,
1038        pos: (f64, f64),
1039        style: &TextStyle,
1040        title: Option<&str>,
1041    ) -> Result<(), RenderError> {
1042        // Review #167 finding 4 (#170): this wrapper must forward to
1043        // `inner.draw_text_with_title`, not `inner.draw_text` — the
1044        // latter is what `DrawBackend::draw_text_with_title`'s own
1045        // default impl calls, and relying on that default here would
1046        // silently drop a truncated legend label's `<title>` tooltip
1047        // (#159) whenever the label is measured through `Measure`
1048        // (`compose::capture_legend`'s collected-legend path).
1049        let w = text.chars().count() as f64 * style.size * 0.6;
1050        let (x0, x1) = match style.anchor {
1051            TextAnchor::Start => (pos.0, pos.0 + w),
1052            TextAnchor::Middle => (pos.0 - w / 2.0, pos.0 + w / 2.0),
1053            TextAnchor::End => (pos.0 - w, pos.0),
1054        };
1055        self.grow(x0, pos.1 - style.size / 2.0, x1, pos.1 + style.size / 2.0);
1056        self.inner.draw_text_with_title(text, pos, style, title)
1057    }
1058    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
1059        for p in pts {
1060            self.grow(p.0, p.1, p.0, p.1);
1061        }
1062        self.inner.draw_polygon(pts, style)
1063    }
1064    fn draw_shape(&mut self, c: (f64, f64), r: f64, style: &PointStyle) -> Result<(), RenderError> {
1065        self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
1066        self.inner.draw_shape(c, r, style)
1067    }
1068    fn plot_area(&self) -> Rect {
1069        self.pa.clone()
1070    }
1071    fn total_area(&self) -> Rect {
1072        self.inner.total_area()
1073    }
1074    fn set_tooltip(&mut self, t: Option<String>) {
1075        self.inner.set_tooltip(t)
1076    }
1077    fn set_mark_axis(&mut self, k: Option<String>) {
1078        self.inner.set_mark_axis(k)
1079    }
1080    fn set_mark_series(&mut self, s: Option<String>) {
1081        self.inner.set_mark_series(s)
1082    }
1083    fn set_mark_value(&mut self, v: Option<String>) {
1084        self.inner.set_mark_value(v)
1085    }
1086    fn warn(&mut self, m: String) {
1087        self.inner.warn(m)
1088    }
1089    fn set_mark_geom(&mut self, geom: Option<&str>) {
1090        self.inner.set_mark_geom(geom)
1091    }
1092    fn set_mark_part(&mut self, part: Option<&str>) {
1093        self.inner.set_mark_part(part)
1094    }
1095    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
1096        self.inner.set_mark_attrs(attrs)
1097    }
1098    fn wants_formatted_values(&self) -> bool {
1099        self.inner.wants_formatted_values()
1100    }
1101    fn set_mark_fvalue(&mut self, value: Option<String>) {
1102        self.inner.set_mark_fvalue(value)
1103    }
1104    fn set_mark_fx(&mut self, value: Option<String>) {
1105        self.inner.set_mark_fx(value)
1106    }
1107}
1108
1109// ─── Operator sugar ─────────────────────────────────────────────────
1110
1111fn join(op: Op, a: GridCell, b: GridCell) -> PlotGrid {
1112    let flatten = |c: GridCell| -> Vec<GridCell> {
1113        match c {
1114            GridCell::Grid(g) if g.op == Some(op) && g.is_plain() => g.cells,
1115            other => vec![other],
1116        }
1117    };
1118    let mut cells = flatten(a);
1119    cells.extend(flatten(b));
1120    PlotGrid {
1121        cells,
1122        op: Some(op),
1123        ..PlotGrid::default()
1124    }
1125}
1126
1127impl PlotGrid {
1128    /// Built purely by an operator chain (no explicit layout/annotations), so
1129    /// it can be flattened into a longer chain of the same operator.
1130    fn is_plain(&self) -> bool {
1131        self.ncol.is_none()
1132            && self.nrow.is_none()
1133            && self.widths.is_empty()
1134            && self.heights.is_empty()
1135            && self.title.is_none()
1136            && self.subtitle.is_none()
1137            && self.caption.is_none()
1138            && self.tags.is_none()
1139            && !self.collect_legends
1140    }
1141}
1142
1143macro_rules! grid_ops {
1144    ($lhs:ty, $rhs:ty) => {
1145        /// `a | b`: place side by side (patchwork).
1146        impl BitOr<$rhs> for $lhs {
1147            type Output = PlotGrid;
1148            fn bitor(self, rhs: $rhs) -> PlotGrid {
1149                join(Op::Row, self.into(), rhs.into())
1150            }
1151        }
1152        /// `a / b`: stack vertically (patchwork).
1153        impl Div<$rhs> for $lhs {
1154            type Output = PlotGrid;
1155            fn div(self, rhs: $rhs) -> PlotGrid {
1156                join(Op::Col, self.into(), rhs.into())
1157            }
1158        }
1159    };
1160}
1161
1162grid_ops!(GGPlot, GGPlot);
1163grid_ops!(GGPlot, PlotGrid);
1164grid_ops!(PlotGrid, GGPlot);
1165grid_ops!(PlotGrid, PlotGrid);
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    #[test]
1172    fn tag_sequences() {
1173        let l: Vec<String> = (0..28).map(|i| TagLevels::Lower.tag(i)).collect();
1174        assert_eq!(&l[..3], &["a", "b", "c"]);
1175        assert_eq!(l[25], "z");
1176        assert_eq!(l[26], "aa");
1177        assert_eq!(l[27], "ab");
1178        assert_eq!(TagLevels::Upper.tag(1), "B");
1179        assert_eq!(TagLevels::Numeric.tag(9), "10");
1180        assert_eq!(TagLevels::LowerRoman.tag(3), "iv");
1181        assert_eq!(TagLevels::UpperRoman.tag(13), "XIV");
1182        assert_eq!(TagLevels::Custom(vec!["x".into()]).tag(1), "2");
1183    }
1184
1185    #[test]
1186    fn tracks_are_relative_and_robust() {
1187        let t = tracks(&[2.0, 1.0], 2, 300.0, 0.0);
1188        assert_eq!(t, vec![(0.0, 200.0), (200.0, 100.0)]);
1189        let t = tracks(&[f64::NAN, -1.0], 2, 100.0, 10.0);
1190        assert_eq!(t, vec![(0.0, 45.0), (55.0, 45.0)]);
1191        let t = tracks(&[], 3, 30.0, 100.0);
1192        assert!(t.iter().all(|(p, s)| p.is_finite() && *s == 0.0));
1193    }
1194
1195    /// `Measure` (the legend bbox pre-pass) forwards every `DrawBackend`
1196    /// method to its inner backend and grows its tracked extent to include
1197    /// a quadratic curve's control points — not just straight lines/shapes.
1198    #[test]
1199    fn measure_forwards_draw_quadratic_and_mark_metadata_and_grows_its_extent() {
1200        let pa = Rect {
1201            x: 0.0,
1202            y: 0.0,
1203            width: 100.0,
1204            height: 100.0,
1205        };
1206        let mut backend = SvgBackend::new(100, 100, pa.clone());
1207        backend.set_formatted_values(true);
1208        let mut meas = Measure::new(&mut backend, pa);
1209
1210        assert!(meas.extent().is_none());
1211        // B2-4: Measure forwards the formatted-values query/setters too.
1212        assert!(meas.wants_formatted_values());
1213        meas.set_mark_geom(Some("line"));
1214        meas.set_mark_part(Some("arrow"));
1215        meas.set_mark_attrs(vec![("data-x".to_string(), "v".to_string())]);
1216        meas.set_mark_fvalue(Some("1,234.5".to_string()));
1217        meas.set_mark_fx(Some("2026-10-12".to_string()));
1218        meas.draw_quadratic(
1219            (10.0, 20.0),
1220            (90.0, 5.0),
1221            (50.0, 80.0),
1222            &LineStyle::default(),
1223        )
1224        .unwrap();
1225        meas.set_mark_geom(None);
1226        meas.set_mark_part(None);
1227        meas.set_mark_attrs(Vec::new());
1228        meas.set_mark_fvalue(None);
1229        meas.set_mark_fx(None);
1230
1231        let (x0, y0, x1, y1) = meas.extent().expect("extent after drawing");
1232        assert_eq!((x0, y0, x1, y1), (10.0, 5.0, 90.0, 80.0));
1233
1234        // The marks actually reached the inner SvgBackend (data-part, the
1235        // data_attr, the mark class and the formatted values all appear in
1236        // its output).
1237        let svg = backend.finish();
1238        assert!(svg.contains("data-part=\"arrow\""));
1239        assert!(svg.contains("data-x=\"v\""));
1240        assert!(svg.contains("gg-arrow"));
1241        assert!(svg.contains("data-fvalue=\"1,234.5\""));
1242        assert!(svg.contains("data-fx=\"2026-10-12\""));
1243    }
1244
1245    /// #170 / review #167 finding 4: `Measure::draw_text_with_title` must
1246    /// forward the `<title>` to its inner `SvgBackend`, not fall through
1247    /// to `DrawBackend::draw_text_with_title`'s default impl (which calls
1248    /// `self.draw_text`, and `Measure::draw_text` only ever calls
1249    /// `inner.draw_text` — dropping the title). This only matters once a
1250    /// `<title>` is actually passed through a `Measure`-wrapped backend
1251    /// (today that's `compose::capture_legend`'s collected-legend path, a
1252    /// `PlotGrid`'s B3-2 label truncation doesn't run yet, so the bug is
1253    /// latent rather than reachable from `PlotGrid`'s own public API —
1254    /// hence this unit test against `Measure` directly).
1255    #[test]
1256    fn measure_forwards_the_title_to_its_inner_backend() {
1257        let pa = Rect {
1258            x: 0.0,
1259            y: 0.0,
1260            width: 100.0,
1261            height: 100.0,
1262        };
1263        let mut backend = SvgBackend::new(100, 100, pa.clone());
1264        let mut meas = Measure::new(&mut backend, pa);
1265        meas.draw_text_with_title(
1266            "Grp…",
1267            (10.0, 20.0),
1268            &TextStyle {
1269                size: 12.0,
1270                anchor: TextAnchor::Start,
1271                ..Default::default()
1272            },
1273            Some("Group 01"),
1274        )
1275        .unwrap();
1276        let svg = backend.finish();
1277        assert!(
1278            svg.contains("<title>Group 01</title>"),
1279            "the full label should reach the inner backend's <title>: {svg}"
1280        );
1281    }
1282}