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}
187
188impl Default for PlotGrid {
189    fn default() -> Self {
190        PlotGrid {
191            cells: Vec::new(),
192            ncol: None,
193            nrow: None,
194            byrow: true,
195            widths: Vec::new(),
196            heights: Vec::new(),
197            title: None,
198            subtitle: None,
199            caption: None,
200            tags: None,
201            tag_prefix: String::new(),
202            tag_suffix: String::new(),
203            collect_legends: false,
204            legend_position: GridLegendPosition::Right,
205            spacing: 0.0,
206            theme: Theme::default(),
207            op: None,
208        }
209    }
210}
211
212/// Upper bound on grid columns/rows — a guard against absurd user input.
213const MAX_TRACKS: usize = 10_000;
214
215impl PlotGrid {
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Append a plot (or a nested grid) as the next cell.
221    #[allow(clippy::should_implement_trait)]
222    pub fn add(mut self, cell: impl Into<GridCell>) -> Self {
223        self.cells.push(cell.into());
224        self
225    }
226
227    /// Append several plots.
228    pub fn add_all(mut self, plots: impl IntoIterator<Item = GGPlot>) -> Self {
229        self.cells.extend(plots.into_iter().map(GridCell::from));
230        self
231    }
232
233    /// Append an empty cell (patchwork's `plot_spacer()`).
234    pub fn add_spacer(mut self) -> Self {
235        self.cells.push(GridCell::Spacer);
236        self
237    }
238
239    /// Number of columns.
240    pub fn ncol(mut self, n: usize) -> Self {
241        self.ncol = (n > 0).then_some(n.min(MAX_TRACKS));
242        self
243    }
244
245    /// Number of rows.
246    pub fn nrow(mut self, n: usize) -> Self {
247        self.nrow = (n > 0).then_some(n.min(MAX_TRACKS));
248        self
249    }
250
251    /// Fill cells row by row (default) or column by column.
252    pub fn byrow(mut self, byrow: bool) -> Self {
253        self.byrow = byrow;
254        self
255    }
256
257    /// Relative column widths (recycled; non-positive / non-finite = 1).
258    pub fn widths(mut self, widths: &[f64]) -> Self {
259        self.widths = widths.to_vec();
260        self
261    }
262
263    /// Relative row heights (recycled; non-positive / non-finite = 1).
264    pub fn heights(mut self, heights: &[f64]) -> Self {
265        self.heights = heights.to_vec();
266        self
267    }
268
269    /// Title over the whole grid.
270    pub fn title(mut self, title: &str) -> Self {
271        self.title = Some(title.to_string());
272        self
273    }
274
275    /// Subtitle over the whole grid.
276    pub fn subtitle(mut self, subtitle: &str) -> Self {
277        self.subtitle = Some(subtitle.to_string());
278        self
279    }
280
281    /// Caption under the whole grid.
282    pub fn caption(mut self, caption: &str) -> Self {
283        self.caption = Some(caption.to_string());
284        self
285    }
286
287    /// Tag the panels (`labs(tag)` of each sub-plot is replaced).
288    pub fn tag_levels(mut self, levels: TagLevels) -> Self {
289        self.tags = Some(levels);
290        self
291    }
292
293    /// Text around each tag, e.g. `("(", ")")` for `(a)`.
294    pub fn tag_affixes(mut self, prefix: &str, suffix: &str) -> Self {
295        self.tag_prefix = prefix.to_string();
296        self.tag_suffix = suffix.to_string();
297        self
298    }
299
300    /// Collect the sub-plots' legends into one strip (identical legends are
301    /// drawn once). Sub-plots with an inside or hidden legend keep it.
302    pub fn collect_legends(mut self, collect: bool) -> Self {
303        self.collect_legends = collect;
304        self
305    }
306
307    /// Where collected legends go.
308    pub fn legend_position(mut self, pos: GridLegendPosition) -> Self {
309        self.legend_position = pos;
310        self
311    }
312
313    /// Gap between cells, in px (default 0).
314    pub fn spacing(mut self, px: f64) -> Self {
315        self.spacing = if px.is_finite() { px.max(0.0) } else { 0.0 };
316        self
317    }
318
319    /// Theme for the grid's own title/subtitle/caption and the collected
320    /// legends' text.
321    pub fn theme(mut self, theme: Theme) -> Self {
322        self.theme = theme;
323        self
324    }
325
326    /// Number of cells (plots, nested grids and spacers).
327    pub fn len(&self) -> usize {
328        self.cells.len()
329    }
330
331    /// Whether the grid has no cells.
332    pub fn is_empty(&self) -> bool {
333        self.cells.is_empty()
334    }
335
336    /// Render at 800×600.
337    pub fn render_svg_native(self) -> Result<String, GGError> {
338        self.render_svg_native_with_size(800, 600)
339    }
340
341    /// Render the grid to one standalone SVG document of `w`×`h` px. The root
342    /// carries `data-grid="<rows> <cols>"` (and no `data-plot`: each sub-`<svg>`
343    /// has its own).
344    pub fn render_svg_native_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
345        Ok(self.render_page(w, h)?.0.finish())
346    }
347
348    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
349    /// also returning every sub-plot's warnings, prefixed `panel <tag>: `.
350    pub fn render_svg_native_with_warnings(
351        self,
352        w: u32,
353        h: u32,
354    ) -> Result<(String, Vec<String>), GGError> {
355        let (page, warnings) = self.render_page(w, h)?;
356        Ok((page.finish(), warnings))
357    }
358
359    /// Render as a nested `<svg>` fragment positioned at `(x, y)` — for
360    /// composing the grid into a larger SVG (cf. [`GGPlot::render_svg_native_at`]).
361    pub fn render_svg_native_at(self, x: f64, y: f64, w: u32, h: u32) -> Result<String, GGError> {
362        Ok(self.render_page(w, h)?.0.finish_fragment(x, y))
363    }
364
365    fn render_page(self, w: u32, h: u32) -> Result<(SvgBackend, Vec<String>), GGError> {
366        let (w, h) = (w.max(1), h.max(1));
367        let mut theme = self.theme.clone();
368        theme.resolve_inheritance();
369        let collect = self.collect_legends;
370        let legend_pos = self.legend_position;
371
372        // Phase 1 (size-independent): build every plot, assign tags and
373        // capture legends to collect.
374        let mut ctx = PrepCtx {
375            tags: self
376                .tags
377                .clone()
378                .map(|t| (t, self.tag_prefix.clone(), self.tag_suffix.clone())),
379            next_tag: 0,
380            next_index: 0,
381            collect: collect.then_some(legend_pos),
382            legends: Vec::new(),
383        };
384        let prepared = prepare_grid(self, &mut ctx)?;
385        let legends = ctx.legends;
386
387        // Phase 2: page layout.
388        let full = Rect {
389            x: 0.0,
390            y: 0.0,
391            width: w as f64,
392            height: h as f64,
393        };
394        let mut page = SvgBackend::new(w, h, full.clone());
395        page.without_plot_attr();
396        let (rows, cols) = prepared.dims();
397        page.set_root_attrs(vec![("data-grid".into(), format!("{rows} {cols}"))]);
398        if let Some(fill) = theme
399            .plot_background
400            .fill
401            .filter(|_| theme.plot_background.visible)
402        {
403            page.draw_rect(
404                (0.0, 0.0),
405                (w as f64, h as f64),
406                &RectStyle {
407                    fill: Some(fill),
408                    stroke: None,
409                    stroke_width: 0.0,
410                    alpha: 1.0,
411                    clip: false,
412                },
413            )
414            .map_err(GGError::Render)?;
415        }
416
417        let mut body = full;
418        if !legends.is_empty() {
419            let gap = theme.legend_spacing.max(0.0) * 2.0;
420            let pad = 8.0;
421            match legend_pos {
422                GridLegendPosition::Right => {
423                    let strip_w = legends.iter().map(|l| l.w).fold(0.0, f64::max) + 2.0 * pad;
424                    let strip_w = strip_w.min(body.width * 0.4);
425                    body.width -= strip_w;
426                    let total_h =
427                        legends.iter().map(|l| l.h).sum::<f64>() + gap * (legends.len() - 1) as f64;
428                    let mut y = (body.y + (body.height - total_h) / 2.0).max(body.y + pad);
429                    for l in &legends {
430                        place_legend(&mut page, l, body.x + body.width + pad, y);
431                        y += l.h + gap;
432                    }
433                }
434                GridLegendPosition::Bottom => {
435                    let strip_h = legends.iter().map(|l| l.h).fold(0.0, f64::max) + 2.0 * pad;
436                    let strip_h = strip_h.min(body.height * 0.4);
437                    body.height -= strip_h;
438                    let total_w =
439                        legends.iter().map(|l| l.w).sum::<f64>() + gap * (legends.len() - 1) as f64;
440                    let mut x = (body.x + (body.width - total_w) / 2.0).max(body.x + pad);
441                    for l in &legends {
442                        place_legend(&mut page, l, x, body.y + body.height + pad);
443                        x += l.w + gap;
444                    }
445                }
446            }
447        }
448
449        // Phase 3: render cells into their rects.
450        let mut warnings = Vec::new();
451        render_prepared(prepared, &body, &mut page, &mut warnings)?;
452        Ok((page, warnings))
453    }
454}
455
456/// A collected legend: rendered markup (also its identity) and extent.
457struct Legend {
458    body: String,
459    x0: f64,
460    y0: f64,
461    w: f64,
462    h: f64,
463}
464
465fn place_legend(page: &mut SvgBackend, l: &Legend, x: f64, y: f64) {
466    let (tx, ty) = (x - l.x0, y - l.y0);
467    if tx.is_finite() && ty.is_finite() {
468        page.push_raw(&format!(
469            "<g class=\"legend\" transform=\"translate({tx:.2} {ty:.2})\">{}</g>",
470            l.body
471        ));
472    }
473}
474
475struct PrepCtx {
476    tags: Option<(TagLevels, String, String)>,
477    next_tag: usize,
478    next_index: usize,
479    collect: Option<GridLegendPosition>,
480    legends: Vec<Legend>,
481}
482
483/// A built sub-plot awaiting its cell size.
484struct PreparedPlot {
485    built: crate::build::BuiltPlot,
486    meta: RenderMeta,
487    panel: String,
488}
489
490enum Prepared {
491    Plot(Box<PreparedPlot>),
492    Grid(Box<PreparedGrid>),
493    Spacer,
494}
495
496struct PreparedGrid {
497    cells: Vec<Prepared>,
498    ncol: usize,
499    nrow: usize,
500    byrow: bool,
501    widths: Vec<f64>,
502    heights: Vec<f64>,
503    title: Option<String>,
504    subtitle: Option<String>,
505    caption: Option<String>,
506    spacing: f64,
507    theme: Theme,
508}
509
510impl Prepared {
511    fn dims(&self) -> (usize, usize) {
512        match self {
513            Prepared::Grid(g) => (g.nrow, g.ncol),
514            _ => (1, 1),
515        }
516    }
517}
518
519fn prepare_grid(grid: PlotGrid, ctx: &mut PrepCtx) -> Result<Prepared, GGError> {
520    let n = grid.cells.len();
521    let (ncol, nrow) = match (grid.ncol, grid.nrow, grid.op) {
522        (Some(c), Some(r), _) => {
523            // Grow rows if the cells don't fit.
524            (c, r.max(n.div_ceil(c)))
525        }
526        (Some(c), None, _) => (c, n.div_ceil(c).max(1)),
527        (None, Some(r), _) => (n.div_ceil(r).max(1), r),
528        (None, None, Some(Op::Row)) => (n.max(1), 1),
529        (None, None, Some(Op::Col)) => (1, n.max(1)),
530        (None, None, None) => {
531            let c = ((n as f64).sqrt().ceil() as usize).max(1);
532            (c, n.div_ceil(c).max(1))
533        }
534    };
535    let mut theme = grid.theme;
536    theme.resolve_inheritance();
537    let mut cells = Vec::with_capacity(n);
538    for cell in grid.cells {
539        cells.push(match cell {
540            GridCell::Spacer => Prepared::Spacer,
541            GridCell::Grid(g) => prepare_grid(*g, ctx)?,
542            GridCell::Plot(p) => Prepared::Plot(Box::new(prepare_plot(*p, ctx)?)),
543        });
544    }
545    Ok(Prepared::Grid(Box::new(PreparedGrid {
546        cells,
547        ncol,
548        nrow,
549        byrow: grid.byrow,
550        widths: grid.widths,
551        heights: grid.heights,
552        title: grid.title,
553        subtitle: grid.subtitle,
554        caption: grid.caption,
555        spacing: grid.spacing,
556        theme,
557    })))
558}
559
560fn prepare_plot(mut plot: GGPlot, ctx: &mut PrepCtx) -> Result<PreparedPlot, GGError> {
561    ctx.next_index += 1;
562    if let Some((levels, pre, suf)) = &ctx.tags {
563        plot.labels.tag = Some(format!("{pre}{}{suf}", levels.tag(ctx.next_tag)));
564        ctx.next_tag += 1;
565    }
566    let panel = plot
567        .labels
568        .tag
569        .clone()
570        .unwrap_or_else(|| ctx.next_index.to_string());
571
572    // Collect an outside legend: suppress it in the plot, draw it separately.
573    let original = plot.theme.legend_position.clone();
574    let collect = ctx.collect.filter(|_| {
575        matches!(
576            original,
577            LegendPosition::Right
578                | LegendPosition::Left
579                | LegendPosition::Top
580                | LegendPosition::Bottom
581        )
582    });
583    if collect.is_some() {
584        plot.theme.legend_position = LegendPosition::None;
585    }
586    let (built, mut meta) = plot.build_for_render()?;
587    if let Some(pos) = collect {
588        meta.has_legend = false;
589        if let Some(l) = capture_legend(&built, pos).map_err(GGError::Render)? {
590            if !ctx.legends.iter().any(|o| o.body == l.body) {
591                ctx.legends.push(l);
592            }
593        }
594    }
595    Ok(PreparedPlot { built, meta, panel })
596}
597
598/// Draw a built plot's legend in isolation, measuring its extent.
599fn capture_legend(
600    built: &crate::build::BuiltPlot,
601    pos: GridLegendPosition,
602) -> Result<Option<Legend>, RenderError> {
603    let mut theme = built.theme.clone();
604    let m = theme.legend_margin.clone();
605    // Fake a zero-size panel so the legend's origin lands at (0, 0).
606    let (pa, lp) = match pos {
607        GridLegendPosition::Right => (
608            Rect {
609                x: -m.left,
610                y: -m.top,
611                width: 0.0,
612                height: 0.0,
613            },
614            LegendPosition::Right,
615        ),
616        GridLegendPosition::Bottom => (
617            Rect {
618                x: -m.left,
619                y: -(m.top + 30.0),
620                width: 0.0,
621                height: 0.0,
622            },
623            LegendPosition::Bottom,
624        ),
625    };
626    theme.legend_position = lp;
627    let mut svg = SvgBackend::new(1, 1, pa.clone());
628    let mut meas = Measure::new(&mut svg, pa.clone());
629    legend::draw_legend(
630        &built.scales,
631        &theme,
632        &pa,
633        &mut meas,
634        &built.guide_legend,
635        &built.suppressed_aes,
636    )?;
637    let ext = meas.extent();
638    if svg.body().is_empty() {
639        return Ok(None);
640    }
641    let (x0, y0, x1, y1) = match ext {
642        Some(e) => e,
643        None => return Ok(None),
644    };
645    Ok(Some(Legend {
646        body: svg.body().to_string(),
647        x0,
648        y0,
649        w: (x1 - x0).max(0.0),
650        h: (y1 - y0).max(0.0),
651    }))
652}
653
654/// Normalised track sizes: `n` relative weights (recycled, invalid → 1).
655fn tracks(weights: &[f64], n: usize, total: f64, gap: f64) -> Vec<(f64, f64)> {
656    let ws: Vec<f64> = (0..n)
657        .map(|i| {
658            if weights.is_empty() {
659                1.0
660            } else {
661                let w = weights[i % weights.len()];
662                if w.is_finite() && w > 0.0 {
663                    w
664                } else {
665                    1.0
666                }
667            }
668        })
669        .collect();
670    let sum: f64 = ws.iter().sum();
671    let avail = (total - gap * n.saturating_sub(1) as f64).max(0.0);
672    let mut pos = 0.0;
673    ws.iter()
674        .map(|w| {
675            let size = avail * w / sum;
676            let t = (pos, size);
677            pos += size + gap;
678            t
679        })
680        .collect()
681}
682
683fn text_style(el: &crate::theme::elements::ElementText, anchor: TextAnchor) -> TextStyle {
684    TextStyle {
685        color: el.color,
686        size: el.size,
687        anchor,
688        angle: 0.0,
689        family: (!el.family.is_empty()).then(|| el.family.clone()),
690        face: el.face,
691    }
692}
693
694fn hjust_x(el: &crate::theme::elements::ElementText, x0: f64, x1: f64) -> (f64, TextAnchor) {
695    let hj = if el.hjust.is_finite() {
696        el.hjust.clamp(0.0, 1.0)
697    } else {
698        0.0
699    };
700    let anchor = if hj <= 0.02 {
701        TextAnchor::Start
702    } else if hj >= 0.98 {
703        TextAnchor::End
704    } else {
705        TextAnchor::Middle
706    };
707    (x0 + hj * (x1 - x0), anchor)
708}
709
710fn render_prepared(
711    prepared: Prepared,
712    area: &Rect,
713    page: &mut SvgBackend,
714    warnings: &mut Vec<String>,
715) -> Result<(), GGError> {
716    match prepared {
717        Prepared::Spacer => Ok(()),
718        Prepared::Plot(p) => {
719            let PreparedPlot {
720                mut built,
721                meta,
722                panel,
723            } = *p;
724            let (cw, ch) = (
725                area.width.floor().max(1.0) as u32,
726                area.height.floor().max(1.0) as u32,
727            );
728            let layout = GGPlot::layout_built(&mut built, &meta, cw, ch);
729            let mut backend = SvgBackend::new(cw, ch, layout.plot_area.clone());
730            let mut attrs = root_data_attrs(&built);
731            attrs.push(("data-panel".into(), panel.clone()));
732            backend.set_root_attrs(attrs);
733            PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
734            warnings.extend(
735                built
736                    .warnings
737                    .iter()
738                    .cloned()
739                    .chain(backend.take_warnings())
740                    .map(|w| format!("panel {panel}: {w}")),
741            );
742            page.push_raw(&backend.finish_fragment(area.x, area.y));
743            Ok(())
744        }
745        Prepared::Grid(g) => {
746            let g = *g;
747            let theme = &g.theme;
748            let m = &theme.plot_margin;
749            // Header / footer bands.
750            let mut top = area.y;
751            let (x0, x1) = (area.x + m.left, area.x + area.width - m.right);
752            if let Some(t) = &g.title {
753                top += m.top;
754                let (tx, anchor) = hjust_x(&theme.title, x0, x1);
755                page.draw_text(
756                    t,
757                    (tx, top + theme.title.size * 0.8),
758                    &text_style(&theme.title, anchor),
759                )
760                .map_err(GGError::Render)?;
761                top += theme.title.size * 1.6;
762            }
763            if let Some(s) = &g.subtitle {
764                if g.title.is_none() {
765                    top += m.top;
766                }
767                let (sx, anchor) = hjust_x(&theme.subtitle, x0, x1);
768                page.draw_text(
769                    s,
770                    (sx, top + theme.subtitle.size * 0.7),
771                    &text_style(&theme.subtitle, anchor),
772                )
773                .map_err(GGError::Render)?;
774                top += theme.subtitle.size * 1.5;
775            }
776            let mut bottom = area.y + area.height;
777            if let Some(c) = &g.caption {
778                let (cx, anchor) = hjust_x(&theme.caption, x0, x1);
779                bottom -= theme.caption.size * 1.8;
780                page.draw_text(
781                    c,
782                    (cx, bottom + theme.caption.size * 0.9),
783                    &text_style(&theme.caption, anchor),
784                )
785                .map_err(GGError::Render)?;
786            }
787            let body_h = (bottom - top).max(1.0);
788            let cols = tracks(&g.widths, g.ncol, area.width, g.spacing);
789            let rows = tracks(&g.heights, g.nrow, body_h, g.spacing);
790            for (k, cell) in g.cells.into_iter().enumerate() {
791                let (r, c) = if g.byrow {
792                    (k / g.ncol, k % g.ncol)
793                } else {
794                    (k % g.nrow, k / g.nrow)
795                };
796                if r >= g.nrow || c >= g.ncol {
797                    // More cells than nrow × ncol: the rest do not fit.
798                    warnings.push(format!(
799                        "plot_grid: cell {} does not fit a {}×{} grid; skipped",
800                        k + 1,
801                        g.nrow,
802                        g.ncol
803                    ));
804                    continue;
805                }
806                let rect = Rect {
807                    x: area.x + cols[c].0,
808                    y: top + rows[r].0,
809                    width: cols[c].1,
810                    height: rows[r].1,
811                };
812                render_prepared(cell, &rect, page, warnings)?;
813            }
814            Ok(())
815        }
816    }
817}
818
819/// A backend wrapper that records the bounding box of everything drawn.
820struct Measure<'a> {
821    inner: &'a mut SvgBackend,
822    pa: Rect,
823    bbox: Option<(f64, f64, f64, f64)>,
824}
825
826impl<'a> Measure<'a> {
827    fn new(inner: &'a mut SvgBackend, pa: Rect) -> Self {
828        Measure {
829            inner,
830            pa,
831            bbox: None,
832        }
833    }
834
835    fn grow(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
836        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
837            return;
838        }
839        self.bbox = Some(match self.bbox {
840            None => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),
841            Some((a, b, c, d)) => (
842                a.min(x0.min(x1)),
843                b.min(y0.min(y1)),
844                c.max(x0.max(x1)),
845                d.max(y0.max(y1)),
846            ),
847        });
848    }
849
850    fn extent(&self) -> Option<(f64, f64, f64, f64)> {
851        self.bbox
852    }
853}
854
855impl DrawBackend for Measure<'_> {
856    fn draw_circle(
857        &mut self,
858        c: (f64, f64),
859        r: f64,
860        style: &PointStyle,
861    ) -> Result<(), RenderError> {
862        self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
863        self.inner.draw_circle(c, r, style)
864    }
865    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
866        for p in pts {
867            self.grow(p.0, p.1, p.0, p.1);
868        }
869        self.inner.draw_line(pts, style)
870    }
871    fn draw_rect(
872        &mut self,
873        a: (f64, f64),
874        b: (f64, f64),
875        style: &RectStyle,
876    ) -> Result<(), RenderError> {
877        self.grow(a.0, a.1, b.0, b.1);
878        self.inner.draw_rect(a, b, style)
879    }
880    fn draw_text(
881        &mut self,
882        text: &str,
883        pos: (f64, f64),
884        style: &TextStyle,
885    ) -> Result<(), RenderError> {
886        let w = text.chars().count() as f64 * style.size * 0.6;
887        let (x0, x1) = match style.anchor {
888            TextAnchor::Start => (pos.0, pos.0 + w),
889            TextAnchor::Middle => (pos.0 - w / 2.0, pos.0 + w / 2.0),
890            TextAnchor::End => (pos.0 - w, pos.0),
891        };
892        self.grow(x0, pos.1 - style.size / 2.0, x1, pos.1 + style.size / 2.0);
893        self.inner.draw_text(text, pos, style)
894    }
895    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
896        for p in pts {
897            self.grow(p.0, p.1, p.0, p.1);
898        }
899        self.inner.draw_polygon(pts, style)
900    }
901    fn draw_shape(&mut self, c: (f64, f64), r: f64, style: &PointStyle) -> Result<(), RenderError> {
902        self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
903        self.inner.draw_shape(c, r, style)
904    }
905    fn plot_area(&self) -> Rect {
906        self.pa.clone()
907    }
908    fn total_area(&self) -> Rect {
909        self.inner.total_area()
910    }
911    fn set_tooltip(&mut self, t: Option<String>) {
912        self.inner.set_tooltip(t)
913    }
914    fn set_mark_axis(&mut self, k: Option<String>) {
915        self.inner.set_mark_axis(k)
916    }
917    fn set_mark_series(&mut self, s: Option<String>) {
918        self.inner.set_mark_series(s)
919    }
920    fn set_mark_value(&mut self, v: Option<String>) {
921        self.inner.set_mark_value(v)
922    }
923    fn warn(&mut self, m: String) {
924        self.inner.warn(m)
925    }
926}
927
928// ─── Operator sugar ─────────────────────────────────────────────────
929
930fn join(op: Op, a: GridCell, b: GridCell) -> PlotGrid {
931    let flatten = |c: GridCell| -> Vec<GridCell> {
932        match c {
933            GridCell::Grid(g) if g.op == Some(op) && g.is_plain() => g.cells,
934            other => vec![other],
935        }
936    };
937    let mut cells = flatten(a);
938    cells.extend(flatten(b));
939    PlotGrid {
940        cells,
941        op: Some(op),
942        ..PlotGrid::default()
943    }
944}
945
946impl PlotGrid {
947    /// Built purely by an operator chain (no explicit layout/annotations), so
948    /// it can be flattened into a longer chain of the same operator.
949    fn is_plain(&self) -> bool {
950        self.ncol.is_none()
951            && self.nrow.is_none()
952            && self.widths.is_empty()
953            && self.heights.is_empty()
954            && self.title.is_none()
955            && self.subtitle.is_none()
956            && self.caption.is_none()
957            && self.tags.is_none()
958            && !self.collect_legends
959    }
960}
961
962macro_rules! grid_ops {
963    ($lhs:ty, $rhs:ty) => {
964        /// `a | b`: place side by side (patchwork).
965        impl BitOr<$rhs> for $lhs {
966            type Output = PlotGrid;
967            fn bitor(self, rhs: $rhs) -> PlotGrid {
968                join(Op::Row, self.into(), rhs.into())
969            }
970        }
971        /// `a / b`: stack vertically (patchwork).
972        impl Div<$rhs> for $lhs {
973            type Output = PlotGrid;
974            fn div(self, rhs: $rhs) -> PlotGrid {
975                join(Op::Col, self.into(), rhs.into())
976            }
977        }
978    };
979}
980
981grid_ops!(GGPlot, GGPlot);
982grid_ops!(GGPlot, PlotGrid);
983grid_ops!(PlotGrid, GGPlot);
984grid_ops!(PlotGrid, PlotGrid);
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    #[test]
991    fn tag_sequences() {
992        let l: Vec<String> = (0..28).map(|i| TagLevels::Lower.tag(i)).collect();
993        assert_eq!(&l[..3], &["a", "b", "c"]);
994        assert_eq!(l[25], "z");
995        assert_eq!(l[26], "aa");
996        assert_eq!(l[27], "ab");
997        assert_eq!(TagLevels::Upper.tag(1), "B");
998        assert_eq!(TagLevels::Numeric.tag(9), "10");
999        assert_eq!(TagLevels::LowerRoman.tag(3), "iv");
1000        assert_eq!(TagLevels::UpperRoman.tag(13), "XIV");
1001        assert_eq!(TagLevels::Custom(vec!["x".into()]).tag(1), "2");
1002    }
1003
1004    #[test]
1005    fn tracks_are_relative_and_robust() {
1006        let t = tracks(&[2.0, 1.0], 2, 300.0, 0.0);
1007        assert_eq!(t, vec![(0.0, 200.0), (200.0, 100.0)]);
1008        let t = tracks(&[f64::NAN, -1.0], 2, 100.0, 10.0);
1009        assert_eq!(t, vec![(0.0, 45.0), (55.0, 45.0)]);
1010        let t = tracks(&[], 3, 30.0, 100.0);
1011        assert!(t.iter().all(|(p, s)| p.is_finite() && *s == 0.0));
1012    }
1013}