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::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/// Panel-tag sequence (patchwork's `tag_levels`).
41#[derive(Clone, Debug, PartialEq)]
42pub enum TagLevels {
43    /// `a`, `b`, … `z`, `aa`, …
44    Lower,
45    /// `A`, `B`, …
46    Upper,
47    /// `1`, `2`, …
48    Numeric,
49    /// `i`, `ii`, `iii`, …
50    LowerRoman,
51    /// `I`, `II`, …
52    UpperRoman,
53    /// Explicit tags; panels beyond the list are numbered.
54    Custom(Vec<String>),
55}
56
57impl TagLevels {
58    /// The tag of the `i`-th (0-based) panel.
59    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    // Bijective base-26: a..z, aa..az, ba..
73    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/// How a [`PlotGrid`] aligns its own direct cells' plot areas and x scale
115/// within each column (patchwork's `plot_layout(axes = ...)`, applied to
116/// the x axis and plot-area alignment specifically — sipemu/
117/// ggplot-rs#175, tracked from DataZooDE/anofox-visualization#318). Only a
118/// column of [`GridCell::Plot`] cells participates; a [`GridCell::Spacer`]
119/// or a nested [`PlotGrid`] cell keeps its own layout, and a column with
120/// fewer than two plot cells is a no-op. Set via [`PlotGrid::axis_collect`].
121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
122pub enum AxisCollect {
123    /// Each cell keeps its own margins, x axis and x domain (default).
124    #[default]
125    Keep,
126    /// Equal left/right plot-area insets within each column, from the
127    /// widest y-axis label requirement in that column — the same idea as
128    /// anofox-visualization's `track.rs` `left = max(...)` fallback, done
129    /// here as one layout pass (`GGPlot::layout_built`'s own arithmetic)
130    /// rather than a render-measure-render loop. Every cell in the column
131    /// also switches to [`AxisLabelFit::Shift`](crate::theme::AxisLabelFit::Shift)
132    /// so an overflowing outer tick label can never perturb the alignment.
133    /// A cell set thinner than the plotting engine's 50px panel-height
134    /// floor via [`PlotGrid::heights`](Self::heights) (an
135    /// anofox-visualization `::TRACK` strip, typically 16-30px) gets a
136    /// centred `aspect_ratio` instead, so its panel comes out exactly as
137    /// tall as the cell rather than overflowing it — `track.rs`'s own
138    /// `Fit::correct` `aspect` trick, computed from the same layout pass.
139    /// Domains and axis text are otherwise untouched.
140    Align,
141    /// [`Align`](Self::Align), plus one x domain shared across the column
142    /// — the union of every cell's own trained x domain — with x-axis
143    /// ticks/title/labels drawn only on the bottom cell. Skipped (with a
144    /// warning), for a column whose x scale is discrete or untrained on
145    /// any cell, in which case the column still gets [`Align`](Self::Align)'s
146    /// plot-area alignment.
147    CollectX,
148}
149
150/// Where a [`PlotGrid`] puts its collected legends.
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum GridLegendPosition {
153    /// A column to the right of the grid (default).
154    #[default]
155    Right,
156    /// A row below the grid.
157    Bottom,
158}
159
160/// How a grid was built by the `|` / `/` operators, so chains flatten
161/// (`a | b | c` is one row) while mixed operators nest.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163enum Op {
164    Row,
165    Col,
166}
167
168/// One grid cell.
169pub enum GridCell {
170    Plot(Box<GGPlot>),
171    Grid(Box<PlotGrid>),
172    /// An empty cell (patchwork's `plot_spacer()`).
173    Spacer,
174    /// A non-plot cell — a caller-rendered SVG fragment such as a table,
175    /// a KPI or a note (patchwork's `wrap_elements`, #378).
176    Element(Box<GridElement>),
177}
178
179/// How a [`GridElement`] gets its markup.
180enum ElementContent {
181    /// A fragment drawn in a `width` × `height` coordinate box, scaled into
182    /// the cell keeping its aspect.
183    Fixed {
184        svg: String,
185        width: f64,
186        height: f64,
187    },
188    /// Rendered for the cell's real size.
189    Fill(Box<dyn Fn(f64, f64) -> String + Send + Sync>),
190}
191
192/// A non-plot [`PlotGrid`] cell: an SVG fragment (patchwork's
193/// `wrap_elements`, #378). The markup is inserted verbatim, so escape any
194/// untrusted text in it. It takes part in the grid's widths/heights and
195/// tags, and is skipped by [`AxisCollect`] and legend collection. In the
196/// output it is a nested `<svg x y width height data-cell="i">` (`i` = its
197/// 1-based position among the grid's plot and element cells, the same count
198/// as a plot's untagged `data-panel`) holding the fragment.
199pub struct GridElement {
200    content: ElementContent,
201}
202
203impl GridElement {
204    /// A fragment whose coordinates span `0..width` × `0..height`; the grid
205    /// scales it into the cell keeping its aspect, centred (letterboxed).
206    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    /// A fragment rendered by `render(width, height)` at the cell's real
217    /// size in pixels — for text and tables laid out to fit.
218    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
243/// A patchwork-style arrangement of plots rendered into **one** SVG on the
244/// plotters-free native path.
245///
246/// - cells fill row-major (or column-major with [`byrow(false)`](Self::byrow))
247///   across [`ncol`](Self::ncol) / [`nrow`](Self::nrow) (default: a near-square
248///   grid; `a | b` is one row, `a / b` one column);
249/// - [`widths`](Self::widths) / [`heights`](Self::heights) set relative
250///   column widths / row heights;
251/// - [`title`](Self::title) / [`subtitle`](Self::subtitle) /
252///   [`caption`](Self::caption) annotate the whole grid;
253/// - [`tag_levels`](Self::tag_levels) tags panels `a, b, …` (`A`, `1`, roman,
254///   custom) in reading order, through nested grids;
255/// - [`collect_legends`](Self::collect_legends) removes the sub-plots'
256///   legends and draws each distinct legend once beside the grid (identical
257///   legends are de-duplicated).
258///
259/// Nested grids keep their own layout and annotations; tagging and legend
260/// collection are controlled by the outermost grid.
261pub 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    /// [`PlotGrid::legend_bottom_when_narrow`]'s share, if set (#363).
277    legend_bottom_share: Option<f64>,
278    spacing: f64,
279    theme: Theme,
280    axis_collect: AxisCollect,
281    op: Option<Op>,
282    /// Extra root `<svg>` attributes requested via [`PlotGrid::svg_attr`]/
283    /// [`PlotGrid::svg_attrs`], validated and applied at render time.
284    root_attrs: Vec<(String, String)>,
285    /// Whether to emit a `data-warnings` JSON array of the panels'
286    /// combined build warnings on the root `<svg>`
287    /// ([`PlotGrid::svg_warnings_attr`]). Default off; byte-identical
288    /// output when off.
289    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
320/// Upper bound on grid columns/rows — a guard against absurd user input.
321const MAX_TRACKS: usize = 10_000;
322
323impl PlotGrid {
324    pub fn new() -> Self {
325        Self::default()
326    }
327
328    /// Append a plot (or a nested grid) as the next cell.
329    #[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    /// Append several plots.
336    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    /// Append an empty cell (patchwork's `plot_spacer()`).
342    pub fn add_spacer(mut self) -> Self {
343        self.cells.push(GridCell::Spacer);
344        self
345    }
346
347    /// Number of columns.
348    pub fn ncol(mut self, n: usize) -> Self {
349        self.ncol = (n > 0).then_some(n.min(MAX_TRACKS));
350        self
351    }
352
353    /// Number of rows.
354    pub fn nrow(mut self, n: usize) -> Self {
355        self.nrow = (n > 0).then_some(n.min(MAX_TRACKS));
356        self
357    }
358
359    /// Fill cells row by row (default) or column by column.
360    pub fn byrow(mut self, byrow: bool) -> Self {
361        self.byrow = byrow;
362        self
363    }
364
365    /// Relative column widths (recycled; non-positive / non-finite = 1).
366    pub fn widths(mut self, widths: &[f64]) -> Self {
367        self.widths = widths.to_vec();
368        self
369    }
370
371    /// Relative row heights (recycled; non-positive / non-finite = 1).
372    pub fn heights(mut self, heights: &[f64]) -> Self {
373        self.heights = heights.to_vec();
374        self
375    }
376
377    /// Title over the whole grid.
378    pub fn title(mut self, title: &str) -> Self {
379        self.title = Some(title.to_string());
380        self
381    }
382
383    /// Subtitle over the whole grid.
384    pub fn subtitle(mut self, subtitle: &str) -> Self {
385        self.subtitle = Some(subtitle.to_string());
386        self
387    }
388
389    /// Caption under the whole grid.
390    pub fn caption(mut self, caption: &str) -> Self {
391        self.caption = Some(caption.to_string());
392        self
393    }
394
395    /// Tag the panels (`labs(tag)` of each sub-plot is replaced).
396    pub fn tag_levels(mut self, levels: TagLevels) -> Self {
397        self.tags = Some(levels);
398        self
399    }
400
401    /// Text around each tag, e.g. `("(", ")")` for `(a)`.
402    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    /// Collect the sub-plots' legends into one strip (identical legends are
409    /// drawn once). Sub-plots with an inside or hidden legend keep it.
410    pub fn collect_legends(mut self, collect: bool) -> Self {
411        self.collect_legends = collect;
412        self
413    }
414
415    /// Where collected legends go.
416    pub fn legend_position(mut self, pos: GridLegendPosition) -> Self {
417        self.legend_position = pos;
418        self
419    }
420
421    /// Move a [`Right`](GridLegendPosition::Right) collected-legend column
422    /// to a row below the grid when it would take more than `share` (0..1)
423    /// of the grid's width at render time — the grid counterpart of
424    /// [`GGPlot::legend_bottom_when_narrow`] (#363). The column is roughly
425    /// fixed in size, so on a phone-width render it squeezes the rightmost
426    /// cells; a render wide enough keeps `Right`, unchanged. Off by
427    /// default; no effect on [`Bottom`](GridLegendPosition::Bottom).
428    pub fn legend_bottom_when_narrow(mut self, share: f64) -> Self {
429        self.legend_bottom_share = Some(share);
430        self
431    }
432
433    /// Align this grid's own direct cells' plot areas and/or x scale,
434    /// one column at a time — see [`AxisCollect`] (sipemu/ggplot-rs#175).
435    /// Default [`AxisCollect::Keep`] (no change, byte-identical output).
436    pub fn axis_collect(mut self, mode: AxisCollect) -> Self {
437        self.axis_collect = mode;
438        self
439    }
440
441    /// Gap between cells, in px (default 0).
442    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    /// Theme for the grid's own title/subtitle/caption and the collected
448    /// legends' text.
449    pub fn theme(mut self, theme: Theme) -> Self {
450        self.theme = theme;
451        self
452    }
453
454    /// Add one extra attribute to the root `<svg>` element — see
455    /// [`GGPlot::svg_attr`](crate::plot::GGPlot::svg_attr), same allow-list
456    /// (`data-*`, `aria-*`, `role`, `id`, `class`, `lang`) and the same
457    /// "ignored with a warning" rule for anything else or a collision with
458    /// what ggplot-rs already writes (`data-grid`, or `data-warnings` when
459    /// [`svg_warnings_attr`](Self::svg_warnings_attr) is also on).
460    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    /// [`svg_attr`](Self::svg_attr) for several attributes at once.
466    pub fn svg_attrs(mut self, attrs: impl IntoIterator<Item = (String, String)>) -> Self {
467        self.root_attrs.extend(attrs);
468        self
469    }
470
471    /// Emit a `data-warnings` attribute on the root `<svg>` — a JSON array
472    /// of every panel's combined build warnings, `[]` when there are none.
473    /// Default off, and omitted entirely when off.
474    pub fn svg_warnings_attr(mut self, on: bool) -> Self {
475        self.svg_warnings_attr = on;
476        self
477    }
478
479    /// Number of cells (plots, nested grids and spacers).
480    pub fn len(&self) -> usize {
481        self.cells.len()
482    }
483
484    /// Whether the grid has no cells.
485    pub fn is_empty(&self) -> bool {
486        self.cells.is_empty()
487    }
488
489    /// Render at 800×600.
490    pub fn render_svg_native(self) -> Result<String, GGError> {
491        self.render_svg_native_with_size(800, 600)
492    }
493
494    /// Render the grid to one standalone SVG document of `w`×`h` px. The root
495    /// carries `data-grid="<rows> <cols>"` (and no `data-plot`: each sub-`<svg>`
496    /// has its own).
497    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    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
502    /// also returning every sub-plot's warnings, prefixed `panel <tag>: `.
503    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    /// Render as a nested `<svg>` fragment positioned at `(x, y)` — for
513    /// composing the grid into a larger SVG (cf. [`GGPlot::render_svg_native_at`]).
514    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        // #363: also capture each legend's Bottom layout, to switch to it
525        // once the page width is known.
526        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        // Phase 1 (size-independent): build every plot, assign tags and
534        // capture legends to collect.
535        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            page_w: w as f64,
546            page_h: h as f64,
547            legend_warnings: Vec::new(),
548        };
549        let prepared = prepare_grid(self, &mut ctx)?;
550        let (mut legends, alt_legends): (Vec<Legend>, Vec<Option<Legend>>) =
551            ctx.legends.into_iter().unzip();
552        if let Some((share, alt)) = alt_pos {
553            let strip_w = legends.iter().map(|l| l.w).fold(0.0, f64::max) + 2.0 * LEGEND_PAD;
554            if !legends.is_empty() && strip_w > share * w as f64 {
555                legend_pos = alt;
556                legends = alt_legends.into_iter().flatten().collect();
557            }
558        }
559        let legend_warnings = ctx.legend_warnings;
560
561        // Phase 2: page layout.
562        let full = Rect {
563            x: 0.0,
564            y: 0.0,
565            width: w as f64,
566            height: h as f64,
567        };
568        let mut page = SvgBackend::new(w, h, full.clone());
569        page.without_plot_attr();
570        let (rows, cols) = prepared.dims();
571        let data_grid_attr = ("data-grid".to_string(), format!("{rows} {cols}"));
572        page.set_root_attrs(vec![data_grid_attr.clone()]);
573        if let Some(fill) = theme
574            .plot_background
575            .fill
576            .filter(|_| theme.plot_background.visible)
577        {
578            page.draw_rect(
579                (0.0, 0.0),
580                (w as f64, h as f64),
581                &RectStyle {
582                    fill: Some(fill),
583                    stroke: None,
584                    stroke_width: 0.0,
585                    alpha: 1.0,
586                    clip: false,
587                    corner_radius: 0.0,
588                },
589            )
590            .map_err(GGError::Render)?;
591        }
592
593        let mut body = full;
594        if !legends.is_empty() {
595            let gap = theme.legend_spacing.max(0.0) * 2.0;
596            let pad = LEGEND_PAD;
597            match legend_pos {
598                GridLegendPosition::Right => {
599                    let strip_w = legends.iter().map(|l| l.w).fold(0.0, f64::max) + 2.0 * pad;
600                    let strip_w = strip_w.min(body.width * 0.4);
601                    body.width -= strip_w;
602                    let total_h =
603                        legends.iter().map(|l| l.h).sum::<f64>() + gap * (legends.len() - 1) as f64;
604                    let mut y = (body.y + (body.height - total_h) / 2.0).max(body.y + pad);
605                    for l in &legends {
606                        place_legend(&mut page, l, body.x + body.width + pad, y);
607                        y += l.h + gap;
608                    }
609                }
610                GridLegendPosition::Bottom => {
611                    let strip_h = legends.iter().map(|l| l.h).fold(0.0, f64::max) + 2.0 * pad;
612                    let strip_h = strip_h.min(body.height * 0.4);
613                    body.height -= strip_h;
614                    let total_w =
615                        legends.iter().map(|l| l.w).sum::<f64>() + gap * (legends.len() - 1) as f64;
616                    let mut x = (body.x + (body.width - total_w) / 2.0).max(body.x + pad);
617                    for l in &legends {
618                        place_legend(&mut page, l, x, body.y + body.height + pad);
619                        x += l.w + gap;
620                    }
621                }
622            }
623        }
624
625        // Phase 3: render cells into their rects.
626        let mut warnings = legend_warnings;
627        render_prepared(prepared, &body, &mut page, &mut warnings)?;
628
629        let mut reserved = vec![data_grid_attr];
630        if svg_warnings_attr {
631            reserved.push(("data-warnings".to_string(), String::new()));
632        }
633        let extra = crate::render::svg_backend::validate_root_attrs(
634            &root_attrs,
635            &reserved,
636            "grid",
637            &mut warnings,
638        );
639        page.extend_root_attrs(extra);
640        if svg_warnings_attr {
641            page.extend_root_attrs(vec![(
642                "data-warnings".to_string(),
643                crate::render::svg_backend::warnings_json(&warnings),
644            )]);
645        }
646        Ok((page, warnings))
647    }
648}
649
650/// Padding around the collected-legend strip.
651const LEGEND_PAD: f64 = 8.0;
652
653/// A collected legend: rendered markup (also its identity) and extent.
654struct Legend {
655    body: String,
656    x0: f64,
657    y0: f64,
658    w: f64,
659    h: f64,
660}
661
662fn place_legend(page: &mut SvgBackend, l: &Legend, x: f64, y: f64) {
663    let (tx, ty) = (x - l.x0, y - l.y0);
664    if tx.is_finite() && ty.is_finite() {
665        page.push_raw(&format!(
666            "<g class=\"legend\" transform=\"translate({tx:.2} {ty:.2})\">{}</g>",
667            l.body
668        ));
669    }
670}
671
672struct PrepCtx {
673    tags: Option<(TagLevels, String, String)>,
674    next_tag: usize,
675    next_index: usize,
676    collect: Option<GridLegendPosition>,
677    /// A second position to also capture each legend for (#363's
678    /// narrow-page fallback), chosen in `render_page`.
679    collect_alt: Option<GridLegendPosition>,
680    /// Collected legends, each with its `collect_alt` layout.
681    legends: Vec<(Legend, Option<Legend>)>,
682    /// The grid's overall page size (sipemu/ggplot-rs#155): a collected
683    /// legend's available width/height for its own B3-2 wrap/grid plan,
684    /// since a `PlotGrid`'s legend strip has no other natural bound —
685    /// it's sized by whatever the legend needs, so the legend instead
686    /// wraps to fit within the page itself.
687    page_w: f64,
688    page_h: f64,
689    /// Warnings `capture_legend`'s `plan_all_legends` call produces for a
690    /// collected legend (#170 / review #167 finding 6) — previously
691    /// discarded; merged into `render_page`'s own `warnings` so they
692    /// reach `render_svg_native_with_warnings` / `data-warnings` like
693    /// every other warning.
694    legend_warnings: Vec<String>,
695}
696
697/// A built sub-plot awaiting its cell size.
698struct PreparedPlot {
699    built: crate::build::BuiltPlot,
700    meta: RenderMeta,
701    panel: String,
702    /// The cell's own `GGPlot::svg_formatted_values` setting (review
703    /// PR#141 finding 6, LOW): `build_for_render` consumes the `GGPlot`
704    /// and discards this field (it only matters at render time, and
705    /// `PlotGrid` builds its cells independently of
706    /// `GGPlot::render_native`, the only place that previously read it),
707    /// so it has to be captured here and carried to the cell's own
708    /// backend in `render_prepared`.
709    svg_formatted_values: bool,
710    /// The cell's own `GGPlot::svg_attr`/`svg_attrs` (#208): same reason
711    /// as `svg_formatted_values` above — `build_for_render` discards
712    /// `GGPlot::root_attrs`, so it's captured here and applied in
713    /// `render_prepared`, on a `<g>` wrapping the cell's own content (the
714    /// cell's root `<svg>` carries `root_data_attrs` and `data-panel`
715    /// already; the grid's root carries the *grid's* own `svg_attr`s —
716    /// neither is this cell's to share).
717    root_attrs: Vec<(String, String)>,
718}
719
720/// An element cell awaiting its size.
721struct PreparedElement {
722    element: GridElement,
723    index: usize,
724    tag: Option<String>,
725    /// The enclosing grid's theme, for the tag.
726    theme: Theme,
727}
728
729enum Prepared {
730    Plot(Box<PreparedPlot>),
731    Element(Box<PreparedElement>),
732    Grid(Box<PreparedGrid>),
733    Spacer,
734}
735
736struct PreparedGrid {
737    cells: Vec<Prepared>,
738    ncol: usize,
739    nrow: usize,
740    byrow: bool,
741    widths: Vec<f64>,
742    heights: Vec<f64>,
743    title: Option<String>,
744    subtitle: Option<String>,
745    caption: Option<String>,
746    spacing: f64,
747    theme: Theme,
748    axis_collect: AxisCollect,
749}
750
751impl Prepared {
752    fn dims(&self) -> (usize, usize) {
753        match self {
754            Prepared::Grid(g) => (g.nrow, g.ncol),
755            _ => (1, 1),
756        }
757    }
758}
759
760fn prepare_grid(grid: PlotGrid, ctx: &mut PrepCtx) -> Result<Prepared, GGError> {
761    let n = grid.cells.len();
762    let (ncol, nrow) = match (grid.ncol, grid.nrow, grid.op) {
763        (Some(c), Some(r), _) => {
764            // Grow rows if the cells don't fit.
765            (c, r.max(n.div_ceil(c)))
766        }
767        (Some(c), None, _) => (c, n.div_ceil(c).max(1)),
768        (None, Some(r), _) => (n.div_ceil(r).max(1), r),
769        (None, None, Some(Op::Row)) => (n.max(1), 1),
770        (None, None, Some(Op::Col)) => (1, n.max(1)),
771        (None, None, None) => {
772            let c = ((n as f64).sqrt().ceil() as usize).max(1);
773            (c, n.div_ceil(c).max(1))
774        }
775    };
776    let mut theme = grid.theme;
777    theme.resolve_inheritance();
778    let mut cells = Vec::with_capacity(n);
779    for cell in grid.cells {
780        cells.push(match cell {
781            GridCell::Spacer => Prepared::Spacer,
782            GridCell::Grid(g) => prepare_grid(*g, ctx)?,
783            GridCell::Plot(p) => Prepared::Plot(Box::new(prepare_plot(*p, ctx)?)),
784            GridCell::Element(e) => {
785                ctx.next_index += 1;
786                let tag = ctx
787                    .tags
788                    .as_ref()
789                    .map(|(levels, pre, suf)| format!("{pre}{}{suf}", levels.tag(ctx.next_tag)));
790                if tag.is_some() {
791                    ctx.next_tag += 1;
792                }
793                Prepared::Element(Box::new(PreparedElement {
794                    element: *e,
795                    index: ctx.next_index,
796                    tag,
797                    theme: theme.clone(),
798                }))
799            }
800        });
801    }
802    Ok(Prepared::Grid(Box::new(PreparedGrid {
803        cells,
804        ncol,
805        nrow,
806        byrow: grid.byrow,
807        widths: grid.widths,
808        heights: grid.heights,
809        title: grid.title,
810        subtitle: grid.subtitle,
811        caption: grid.caption,
812        spacing: grid.spacing,
813        theme,
814        axis_collect: grid.axis_collect,
815    })))
816}
817
818fn prepare_plot(mut plot: GGPlot, ctx: &mut PrepCtx) -> Result<PreparedPlot, GGError> {
819    ctx.next_index += 1;
820    if let Some((levels, pre, suf)) = &ctx.tags {
821        plot.labels.tag = Some(format!("{pre}{}{suf}", levels.tag(ctx.next_tag)));
822        ctx.next_tag += 1;
823    }
824    let panel = plot
825        .labels
826        .tag
827        .clone()
828        .unwrap_or_else(|| ctx.next_index.to_string());
829
830    // Collect an outside legend: suppress it in the plot, draw it separately.
831    let original = plot.theme.legend_position.clone();
832    let collect = ctx.collect.filter(|_| {
833        matches!(
834            original,
835            LegendPosition::Right
836                | LegendPosition::Left
837                | LegendPosition::Top
838                | LegendPosition::Bottom
839        )
840    });
841    if collect.is_some() {
842        plot.theme.legend_position = LegendPosition::None;
843    }
844    let svg_formatted_values = plot.svg_formatted_values;
845    let root_attrs = plot.root_attrs.clone();
846    let (built, mut meta) = plot.build_for_render()?;
847    if let Some(pos) = collect {
848        meta.has_legend = false;
849        if let Some(l) = capture_legend(
850            &built,
851            pos,
852            ctx.page_w,
853            ctx.page_h,
854            &mut ctx.legend_warnings,
855        )
856        .map_err(GGError::Render)?
857        {
858            if !ctx.legends.iter().any(|(o, _)| o.body == l.body) {
859                let alt = match ctx.collect_alt {
860                    Some(alt) => {
861                        capture_legend(&built, alt, ctx.page_w, ctx.page_h, &mut Vec::new())
862                            .map_err(GGError::Render)?
863                    }
864                    None => None,
865                };
866                ctx.legends.push((l, alt));
867            }
868        }
869    }
870    Ok(PreparedPlot {
871        built,
872        meta,
873        panel,
874        svg_formatted_values,
875        root_attrs,
876    })
877}
878
879/// Draw a built plot's legend in isolation, measuring its extent.
880///
881/// `page_w`/`page_h` are the grid's overall page size (sipemu/
882/// ggplot-rs#155): a collected legend has no natural width/height bound
883/// of its own — the strip that holds it is sized by whatever the legend
884/// needs — so a discrete guide wraps/grids here against the page itself,
885/// the same way a single plot's legend wraps against its own canvas.
886fn capture_legend(
887    built: &crate::build::BuiltPlot,
888    pos: GridLegendPosition,
889    page_w: f64,
890    page_h: f64,
891    // Review #167 finding 6 (#170): the plan's own `guide_legend:
892    // nrow(0)/ncol(0) is invalid`-style warnings used to go into a
893    // `legend_warnings` local that nothing ever read — this path dropped
894    // them silently. The caller threads its own warnings vec through so
895    // they land in the grid's `render_svg_native_with_warnings` /
896    // `data-warnings`, the same place a single plot's own legend
897    // warnings go (`GGPlot::layout_built` → `built.warnings`).
898    warnings: &mut Vec<String>,
899) -> Result<Option<Legend>, RenderError> {
900    let mut theme = built.theme.clone();
901    let m = theme.legend_margin.clone();
902    // Fake a zero-size panel so the legend's origin lands at (0, 0).
903    let (pa, lp, is_horizontal) = match pos {
904        GridLegendPosition::Right => (
905            Rect {
906                x: -m.left,
907                y: -m.top,
908                width: 0.0,
909                height: 0.0,
910            },
911            LegendPosition::Right,
912            false,
913        ),
914        GridLegendPosition::Bottom => (
915            Rect {
916                x: -m.left,
917                y: -(m.top + 30.0),
918                width: 0.0,
919                height: 0.0,
920            },
921            LegendPosition::Bottom,
922            true,
923        ),
924    };
925    theme.legend_position = lp;
926    let mut svg = SvgBackend::new(1, 1, pa.clone());
927    let mut meas = Measure::new(&mut svg, pa.clone());
928    let avail_width = (page_w + m.left).max(0.0);
929    let avail_height = (page_h + m.top).max(0.0);
930    let legend_plans = legend::plan_all_legends(
931        &built.scales,
932        &built.guide_legend,
933        &theme,
934        &built.suppressed_aes,
935        avail_width,
936        avail_height,
937        is_horizontal,
938        warnings,
939    );
940    legend::draw_legend_planned(
941        &built.scales,
942        &theme,
943        &pa,
944        // Review #167 finding 1: step by the same `avail_width` share
945        // `plan_all_legends` just planned against, not `pa.width` (always
946        // 0.0 — `pa` is a fake zero-size panel so the legend's origin
947        // lands at (0, 0)). Using `pa.width` made every discrete guide
948        // after the first advance by zero, so two collected guides (e.g.
949        // `colour` and `shape`) were drawn on top of each other.
950        avail_width,
951        &mut meas,
952        &built.guide_legend,
953        &built.suppressed_aes,
954        &legend_plans,
955    )?;
956    let ext = meas.extent();
957    if svg.body().is_empty() {
958        return Ok(None);
959    }
960    let (x0, y0, x1, y1) = match ext {
961        Some(e) => e,
962        None => return Ok(None),
963    };
964    Ok(Some(Legend {
965        body: svg.body().to_string(),
966        x0,
967        y0,
968        w: (x1 - x0).max(0.0),
969        h: (y1 - y0).max(0.0),
970    }))
971}
972
973/// Normalised track sizes: `n` relative weights (recycled, invalid → 1).
974fn tracks(weights: &[f64], n: usize, total: f64, gap: f64) -> Vec<(f64, f64)> {
975    let ws: Vec<f64> = (0..n)
976        .map(|i| {
977            if weights.is_empty() {
978                1.0
979            } else {
980                let w = weights[i % weights.len()];
981                if w.is_finite() && w > 0.0 {
982                    w
983                } else {
984                    1.0
985                }
986            }
987        })
988        .collect();
989    let sum: f64 = ws.iter().sum();
990    let avail = (total - gap * n.saturating_sub(1) as f64).max(0.0);
991    let mut pos = 0.0;
992    ws.iter()
993        .map(|w| {
994            let size = avail * w / sum;
995            let t = (pos, size);
996            pos += size + gap;
997            t
998        })
999        .collect()
1000}
1001
1002fn text_style(el: &crate::theme::elements::ElementText, anchor: TextAnchor) -> TextStyle {
1003    TextStyle {
1004        color: el.color,
1005        size: el.size,
1006        anchor,
1007        angle: 0.0,
1008        family: (!el.family.is_empty()).then(|| el.family.clone()),
1009        face: el.face,
1010        bg_colour: None,
1011        bg_r: 0.0,
1012    }
1013}
1014
1015fn hjust_x(el: &crate::theme::elements::ElementText, x0: f64, x1: f64) -> (f64, TextAnchor) {
1016    let hj = if el.hjust.is_finite() {
1017        el.hjust.clamp(0.0, 1.0)
1018    } else {
1019        0.0
1020    };
1021    let anchor = if hj <= 0.02 {
1022        TextAnchor::Start
1023    } else if hj >= 0.98 {
1024        TextAnchor::End
1025    } else {
1026        TextAnchor::Middle
1027    };
1028    (x0 + hj * (x1 - x0), anchor)
1029}
1030
1031fn render_prepared(
1032    prepared: Prepared,
1033    area: &Rect,
1034    page: &mut SvgBackend,
1035    warnings: &mut Vec<String>,
1036) -> Result<(), GGError> {
1037    match prepared {
1038        Prepared::Spacer => Ok(()),
1039        Prepared::Element(e) => {
1040            let PreparedElement {
1041                element,
1042                index,
1043                tag,
1044                theme,
1045            } = *e;
1046            // A tagged element keeps clear of its tag: the tag sits in a
1047            // band of twice the title size at the top (or bottom) of the
1048            // cell — where a plot cell has its own margin — and the
1049            // content takes the rest.
1050            let band = if tag.is_some() {
1051                (2.0 * theme.title.size).min(area.height.max(0.0))
1052            } else {
1053                0.0
1054            };
1055            let top = matches!(
1056                theme.tag_position,
1057                crate::theme::TagPosition::TopLeft | crate::theme::TagPosition::TopRight
1058            );
1059            let (x, w) = (area.x, area.width.max(0.0));
1060            let (y, h) = (
1061                if top { area.y + band } else { area.y },
1062                (area.height - band).max(0.0),
1063            );
1064            let (view_box, body) = match element.content {
1065                ElementContent::Fixed { svg, width, height } => {
1066                    let ok = width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0;
1067                    if !ok {
1068                        warnings.push(format!(
1069                            "plot_grid: element cell {index} has an invalid size {width}×{height}; skipped"
1070                        ));
1071                        return Ok(());
1072                    }
1073                    (format!(" viewBox=\"0 0 {width} {height}\""), svg)
1074                }
1075                ElementContent::Fill(render) => (String::new(), render(w, h)),
1076            };
1077            page.push_raw(&format!(
1078                "<svg x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\"{view_box} data-cell=\"{index}\">{body}</svg>"
1079            ));
1080            // The tag, where a plot cell draws its own (`labs(tag)`).
1081            if let Some(tag) = tag {
1082                let labels = crate::plot::Labels {
1083                    tag: Some(tag),
1084                    ..Default::default()
1085                };
1086                PlotRenderer::draw_cell_tag(&labels, &theme, area, page)
1087                    .map_err(GGError::Render)?;
1088            }
1089            Ok(())
1090        }
1091        Prepared::Plot(p) => {
1092            let PreparedPlot {
1093                mut built,
1094                meta,
1095                panel,
1096                svg_formatted_values,
1097                root_attrs,
1098            } = *p;
1099            let (cw, ch) = (
1100                area.width.floor().max(1.0) as u32,
1101                area.height.floor().max(1.0) as u32,
1102            );
1103            let layout = GGPlot::layout_built(&mut built, &meta, cw, ch);
1104            let mut backend = SvgBackend::new(cw, ch, layout.plot_area.clone());
1105            backend.set_formatted_values(svg_formatted_values);
1106            let mut attrs = root_data_attrs(&built);
1107            attrs.push(("data-panel".into(), panel.clone()));
1108            backend.set_root_attrs(attrs);
1109            PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
1110            // #208: this cell's own `svg_attr`s go on a `<g>` wrapping its
1111            // content, not the cell's root `<svg>` (that one is
1112            // `root_data_attrs` + `data-panel`, ggplot-rs's own) nor the
1113            // grid's root (that one is the *grid's* own `svg_attr`s) — so
1114            // `existing` is empty: nothing built-in to collide with here,
1115            // only a name reused across two `svg_attr` calls.
1116            let mut attr_warnings = Vec::new();
1117            let group_attrs = validate_root_attrs(&root_attrs, &[], "plot", &mut attr_warnings);
1118            backend.wrap_body_in_group(&group_attrs);
1119            warnings.extend(
1120                built
1121                    .warnings
1122                    .iter()
1123                    .cloned()
1124                    .chain(backend.take_warnings())
1125                    .chain(attr_warnings)
1126                    .map(|w| format!("panel {panel}: {w}")),
1127            );
1128            page.push_raw(&backend.finish_fragment(area.x, area.y));
1129            Ok(())
1130        }
1131        Prepared::Grid(g) => {
1132            let mut g = *g;
1133            let theme = &g.theme;
1134            let m = &theme.plot_margin;
1135            // Header / footer bands.
1136            let mut top = area.y;
1137            let (x0, x1) = (area.x + m.left, area.x + area.width - m.right);
1138            if let Some(t) = &g.title {
1139                top += m.top;
1140                let (tx, anchor) = hjust_x(&theme.title, x0, x1);
1141                page.draw_text(
1142                    t,
1143                    (tx, top + theme.title.size * 0.8),
1144                    &text_style(&theme.title, anchor),
1145                )
1146                .map_err(GGError::Render)?;
1147                top += theme.title.size * 1.6;
1148            }
1149            if let Some(s) = &g.subtitle {
1150                if g.title.is_none() {
1151                    top += m.top;
1152                }
1153                let (sx, anchor) = hjust_x(&theme.subtitle, x0, x1);
1154                page.draw_text(
1155                    s,
1156                    (sx, top + theme.subtitle.size * 0.7),
1157                    &text_style(&theme.subtitle, anchor),
1158                )
1159                .map_err(GGError::Render)?;
1160                top += theme.subtitle.size * 1.5;
1161            }
1162            let mut bottom = area.y + area.height;
1163            if let Some(c) = &g.caption {
1164                let (cx, anchor) = hjust_x(&theme.caption, x0, x1);
1165                bottom -= theme.caption.size * 1.8;
1166                page.draw_text(
1167                    c,
1168                    (cx, bottom + theme.caption.size * 0.9),
1169                    &text_style(&theme.caption, anchor),
1170                )
1171                .map_err(GGError::Render)?;
1172            }
1173            let body_h = (bottom - top).max(1.0);
1174            let cols = tracks(&g.widths, g.ncol, area.width, g.spacing);
1175            let rows = tracks(&g.heights, g.nrow, body_h, g.spacing);
1176            if g.axis_collect != AxisCollect::Keep {
1177                align_axes(
1178                    &mut g.cells,
1179                    (g.ncol, g.nrow, g.byrow),
1180                    &cols,
1181                    &rows,
1182                    g.axis_collect,
1183                    warnings,
1184                );
1185            }
1186            for (k, cell) in g.cells.into_iter().enumerate() {
1187                let (r, c) = if g.byrow {
1188                    (k / g.ncol, k % g.ncol)
1189                } else {
1190                    (k % g.nrow, k / g.nrow)
1191                };
1192                if r >= g.nrow || c >= g.ncol {
1193                    // More cells than nrow × ncol: the rest do not fit.
1194                    warnings.push(format!(
1195                        "plot_grid: cell {} does not fit a {}×{} grid; skipped",
1196                        k + 1,
1197                        g.nrow,
1198                        g.ncol
1199                    ));
1200                    continue;
1201                }
1202                let rect = Rect {
1203                    x: area.x + cols[c].0,
1204                    y: top + rows[r].0,
1205                    width: cols[c].1,
1206                    height: rows[r].1,
1207                };
1208                render_prepared(cell, &rect, page, warnings)?;
1209            }
1210            Ok(())
1211        }
1212    }
1213}
1214
1215/// sipemu/ggplot-rs#175: align a grid's own direct cells' plot areas and/or
1216/// x domain, column by column ([`AxisCollect::Align`]/[`AxisCollect::CollectX`]).
1217/// `cols`/`rows` are this grid's own track sizes (same for every cell in a
1218/// column/row), already computed by the caller. Only `Prepared::Plot` cells
1219/// participate; a column with fewer than two of them is left untouched.
1220fn align_axes(
1221    cells: &mut [Prepared],
1222    // (ncol, nrow, byrow) — bundled so the function stays under clippy's
1223    // argument-count limit.
1224    (ncol, nrow, byrow): (usize, usize, bool),
1225    cols: &[(f64, f64)],
1226    rows: &[(f64, f64)],
1227    mode: AxisCollect,
1228    warnings: &mut Vec<String>,
1229) {
1230    let idx_of = |r: usize, c: usize| -> usize {
1231        if byrow {
1232            r * ncol + c
1233        } else {
1234            c * nrow + r
1235        }
1236    };
1237    for c in 0..ncol {
1238        let cw = cols.get(c).map_or(0.0, |t| t.1);
1239        if !cw.is_finite() || cw <= 0.0 {
1240            continue;
1241        }
1242        // (row, cell index) of every plot cell in this column, top to
1243        // bottom. A spacer or a nested grid is skipped — it keeps its own
1244        // layout and doesn't count toward "the bottom cell" either.
1245        let plot_cells: Vec<(usize, usize)> = (0..nrow)
1246            .filter_map(|r| {
1247                let k = idx_of(r, c);
1248                (k < cells.len() && matches!(cells[k], Prepared::Plot(_))).then_some((r, k))
1249            })
1250            .collect();
1251        if plot_cells.len() < 2 {
1252            continue; // nothing to align against in this column
1253        }
1254        let bottom_row = plot_cells.iter().map(|&(r, _)| r).max().unwrap();
1255
1256        // Switch every cell to `Shift` (so an overflowing outer label can
1257        // never grow its margins behind our back) and, under `CollectX`,
1258        // blank every non-bottom cell's x axis *before* measuring, so the
1259        // measurement already reflects what's actually drawn.
1260        for &(r, k) in &plot_cells {
1261            if let Prepared::Plot(p) = &mut cells[k] {
1262                p.built.theme.axis_text_x_fit = AxisLabelFit::Shift;
1263                if mode == AxisCollect::CollectX && r != bottom_row {
1264                    p.built.theme.axis_text_x = ElementText::blank();
1265                    p.built.theme.axis_title_x = ElementText::blank();
1266                    p.built.theme.axis_ticks_x = Some(ElementLine::blank());
1267                }
1268            }
1269        }
1270
1271        // Each cell's own plot-area rect at this column's width — pure
1272        // layout arithmetic (`GGPlot::layout_built`, the same computation
1273        // the real render pass makes), not a drawn-and-measured SVG, so
1274        // this whole pass costs one extra layout per cell, not a redraw.
1275        // `y`/`height` are kept too, for the thin-cell correction below —
1276        // changing a cell's left/right margin never moves them (only
1277        // top/bottom margins and the vertical bands do), so one layout
1278        // call per cell covers both passes.
1279        let mut natural: Vec<(f64, f64, f64, f64, f64)> = Vec::with_capacity(plot_cells.len());
1280        for &(r, k) in &plot_cells {
1281            let ch = rows.get(r).map_or(0.0, |t| t.1);
1282            match &mut cells[k] {
1283                Prepared::Plot(p) => {
1284                    let layout = GGPlot::layout_built(
1285                        &mut p.built,
1286                        &p.meta,
1287                        cw.floor().max(1.0) as u32,
1288                        ch.floor().max(1.0) as u32,
1289                    );
1290                    natural.push((
1291                        layout.plot_area.x,
1292                        layout.plot_area.x + layout.plot_area.width,
1293                        layout.plot_area.y,
1294                        layout.plot_area.height,
1295                        ch,
1296                    ));
1297                }
1298                _ => unreachable!("plot_cells only holds Prepared::Plot indices"),
1299            }
1300        }
1301
1302        // Equal left/right insets: every cell's left margin grows to the
1303        // widest left requirement in the column, and likewise for the
1304        // right (anofox-visualization's `track.rs` `left = max(...)`
1305        // fallback, generalised to both edges and computed once).
1306        let target_left = natural.iter().fold(f64::MIN, |m, &(l, ..)| m.max(l));
1307        let target_right_inset = natural
1308            .iter()
1309            .fold(f64::MIN, |m, &(_, r, ..)| m.max(cw - r));
1310        let width = cw - target_right_inset - target_left;
1311        if target_left.is_finite() && target_right_inset.is_finite() {
1312            for (&(_, k), &(nl, nr, ..)) in plot_cells.iter().zip(&natural) {
1313                if let Prepared::Plot(p) = &mut cells[k] {
1314                    p.built.theme.plot_margin.left += (target_left - nl).max(0.0);
1315                    p.built.theme.plot_margin.right += (target_right_inset - (cw - nr)).max(0.0);
1316                }
1317            }
1318        }
1319
1320        // A cell thinner than the plotting engine's 50px panel-height
1321        // floor (anofox-visualization's `::TRACK` strips, ~16-30px) would
1322        // otherwise overflow its own canvas: `layout_built` never shrinks
1323        // a panel below 50px on its own. Mirror `track.rs`'s `Fit::correct`
1324        // `aspect` trick — a centred `aspect_ratio` set so the panel comes
1325        // out exactly as tall as what's actually left after the margins —
1326        // but computed once from this same `layout_built` measurement
1327        // instead of track.rs's render-measure-render loop. A cell that
1328        // already fits (the common case — a normal chart cell) is
1329        // untouched.
1330        if width.is_finite() && width > 0.0 {
1331            for (&(_, k), &(_, _, y, h, ch)) in plot_cells.iter().zip(&natural) {
1332                if let Prepared::Plot(p) = &mut cells[k] {
1333                    let bottom_budget = ch - p.built.theme.plot_margin.bottom;
1334                    if y + h > bottom_budget + 0.5 {
1335                        let fits = (ch - y - p.built.theme.plot_margin.bottom).max(8.0);
1336                        p.built.theme.aspect_ratio = Some(fits / width);
1337                        p.built.theme.plot_margin.top -= (h - fits) / 2.0;
1338                    }
1339                }
1340            }
1341        }
1342
1343        if mode == AxisCollect::CollectX {
1344            collect_x_domain(cells, &plot_cells, warnings);
1345        }
1346    }
1347}
1348
1349/// Train one x domain across a column's cells — the union of every cell's
1350/// own trained domain — then pin every cell's x scale to it
1351/// ([`ScaleSet::set_limits`](crate::scale::ScaleSet::set_limits), the same
1352/// mechanism `coord_cartesian_zoom` uses). Each scale keeps its own
1353/// `expand`; since every cell now trains the same raw domain, identical
1354/// `expand` settings produce an identical expanded (drawn) domain too.
1355/// Skipped, with a warning, when any cell's x scale is discrete or
1356/// untrained — there's no well-defined union of levels or of nothing.
1357fn collect_x_domain(
1358    cells: &mut [Prepared],
1359    plot_cells: &[(usize, usize)],
1360    warnings: &mut Vec<String>,
1361) {
1362    let mut domains = Vec::with_capacity(plot_cells.len());
1363    for &(_, k) in plot_cells {
1364        let d = match &cells[k] {
1365            Prepared::Plot(p) => match p.built.scales.get(&Aesthetic::X) {
1366                Some(s) if !s.is_discrete() => s.domain(),
1367                _ => None,
1368            },
1369            _ => None,
1370        };
1371        match d {
1372            Some(d) => domains.push(d),
1373            None => {
1374                warnings.push(
1375                    "plot_grid: a column's x scale is discrete or untrained; x domain not \
1376                     collected"
1377                        .to_string(),
1378                );
1379                return;
1380            }
1381        }
1382    }
1383    let lo = domains.iter().map(|d| d.0).fold(f64::INFINITY, f64::min);
1384    let hi = domains
1385        .iter()
1386        .map(|d| d.1)
1387        .fold(f64::NEG_INFINITY, f64::max);
1388    if lo.is_finite() && hi.is_finite() && hi >= lo {
1389        for &(_, k) in plot_cells {
1390            if let Prepared::Plot(p) = &mut cells[k] {
1391                p.built.scales.set_limits(&Aesthetic::X, lo, hi);
1392            }
1393        }
1394    }
1395}
1396
1397/// A backend wrapper that records the bounding box of everything drawn.
1398struct Measure<'a> {
1399    inner: &'a mut SvgBackend,
1400    pa: Rect,
1401    bbox: Option<(f64, f64, f64, f64)>,
1402}
1403
1404impl<'a> Measure<'a> {
1405    fn new(inner: &'a mut SvgBackend, pa: Rect) -> Self {
1406        Measure {
1407            inner,
1408            pa,
1409            bbox: None,
1410        }
1411    }
1412
1413    fn grow(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
1414        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
1415            return;
1416        }
1417        self.bbox = Some(match self.bbox {
1418            None => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),
1419            Some((a, b, c, d)) => (
1420                a.min(x0.min(x1)),
1421                b.min(y0.min(y1)),
1422                c.max(x0.max(x1)),
1423                d.max(y0.max(y1)),
1424            ),
1425        });
1426    }
1427
1428    fn extent(&self) -> Option<(f64, f64, f64, f64)> {
1429        self.bbox
1430    }
1431}
1432
1433impl DrawBackend for Measure<'_> {
1434    fn draw_circle(
1435        &mut self,
1436        c: (f64, f64),
1437        r: f64,
1438        style: &PointStyle,
1439    ) -> Result<(), RenderError> {
1440        self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
1441        self.inner.draw_circle(c, r, style)
1442    }
1443    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
1444        for p in pts {
1445            self.grow(p.0, p.1, p.0, p.1);
1446        }
1447        self.inner.draw_line(pts, style)
1448    }
1449    fn draw_quadratic(
1450        &mut self,
1451        p0: (f64, f64),
1452        ctrl: (f64, f64),
1453        p1: (f64, f64),
1454        style: &LineStyle,
1455    ) -> Result<(), RenderError> {
1456        self.grow(p0.0, p0.1, p0.0, p0.1);
1457        self.grow(ctrl.0, ctrl.1, ctrl.0, ctrl.1);
1458        self.grow(p1.0, p1.1, p1.0, p1.1);
1459        self.inner.draw_quadratic(p0, ctrl, p1, style)
1460    }
1461    fn draw_rect(
1462        &mut self,
1463        a: (f64, f64),
1464        b: (f64, f64),
1465        style: &RectStyle,
1466    ) -> Result<(), RenderError> {
1467        self.grow(a.0, a.1, b.0, b.1);
1468        self.inner.draw_rect(a, b, style)
1469    }
1470    fn draw_text(
1471        &mut self,
1472        text: &str,
1473        pos: (f64, f64),
1474        style: &TextStyle,
1475    ) -> Result<(), RenderError> {
1476        self.draw_text_with_title(text, pos, style, None)
1477    }
1478    fn draw_text_with_title(
1479        &mut self,
1480        text: &str,
1481        pos: (f64, f64),
1482        style: &TextStyle,
1483        title: Option<&str>,
1484    ) -> Result<(), RenderError> {
1485        // Review #167 finding 4 (#170): this wrapper must forward to
1486        // `inner.draw_text_with_title`, not `inner.draw_text` — the
1487        // latter is what `DrawBackend::draw_text_with_title`'s own
1488        // default impl calls, and relying on that default here would
1489        // silently drop a truncated legend label's `<title>` tooltip
1490        // (#159) whenever the label is measured through `Measure`
1491        // (`compose::capture_legend`'s collected-legend path).
1492        let w = text.chars().count() as f64 * style.size * 0.6;
1493        let (x0, x1) = match style.anchor {
1494            TextAnchor::Start => (pos.0, pos.0 + w),
1495            TextAnchor::Middle => (pos.0 - w / 2.0, pos.0 + w / 2.0),
1496            TextAnchor::End => (pos.0 - w, pos.0),
1497        };
1498        self.grow(x0, pos.1 - style.size / 2.0, x1, pos.1 + style.size / 2.0);
1499        self.inner.draw_text_with_title(text, pos, style, title)
1500    }
1501    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
1502        for p in pts {
1503            self.grow(p.0, p.1, p.0, p.1);
1504        }
1505        self.inner.draw_polygon(pts, style)
1506    }
1507    fn draw_shape(&mut self, c: (f64, f64), r: f64, style: &PointStyle) -> Result<(), RenderError> {
1508        self.grow(c.0 - r, c.1 - r, c.0 + r, c.1 + r);
1509        self.inner.draw_shape(c, r, style)
1510    }
1511    fn plot_area(&self) -> Rect {
1512        self.pa.clone()
1513    }
1514    fn total_area(&self) -> Rect {
1515        self.inner.total_area()
1516    }
1517    fn set_tooltip(&mut self, t: Option<String>) {
1518        self.inner.set_tooltip(t)
1519    }
1520    fn set_mark_axis(&mut self, k: Option<String>) {
1521        self.inner.set_mark_axis(k)
1522    }
1523    fn set_mark_series(&mut self, s: Option<String>) {
1524        self.inner.set_mark_series(s)
1525    }
1526    fn set_mark_value(&mut self, v: Option<String>) {
1527        self.inner.set_mark_value(v)
1528    }
1529    fn warn(&mut self, m: String) {
1530        self.inner.warn(m)
1531    }
1532    fn set_mark_geom(&mut self, geom: Option<&str>) {
1533        self.inner.set_mark_geom(geom)
1534    }
1535    fn set_mark_part(&mut self, part: Option<&str>) {
1536        self.inner.set_mark_part(part)
1537    }
1538    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
1539        self.inner.set_mark_attrs(attrs)
1540    }
1541
1542    fn set_mark_class(&mut self, class: Option<String>) {
1543        self.inner.set_mark_class(class)
1544    }
1545
1546    fn set_mark_group(&mut self, group: Option<(String, String)>) {
1547        self.inner.set_mark_group(group)
1548    }
1549
1550    fn set_layer_attrs(&mut self, attrs: Vec<(String, String)>) {
1551        self.inner.set_layer_attrs(attrs)
1552    }
1553    fn wants_formatted_values(&self) -> bool {
1554        self.inner.wants_formatted_values()
1555    }
1556    fn set_mark_fvalue(&mut self, value: Option<String>) {
1557        self.inner.set_mark_fvalue(value)
1558    }
1559    fn set_mark_fx(&mut self, value: Option<String>) {
1560        self.inner.set_mark_fx(value)
1561    }
1562}
1563
1564// ─── Operator sugar ─────────────────────────────────────────────────
1565
1566fn join(op: Op, a: GridCell, b: GridCell) -> PlotGrid {
1567    let flatten = |c: GridCell| -> Vec<GridCell> {
1568        match c {
1569            GridCell::Grid(g) if g.op == Some(op) && g.is_plain() => g.cells,
1570            other => vec![other],
1571        }
1572    };
1573    let mut cells = flatten(a);
1574    cells.extend(flatten(b));
1575    PlotGrid {
1576        cells,
1577        op: Some(op),
1578        ..PlotGrid::default()
1579    }
1580}
1581
1582impl PlotGrid {
1583    /// Built purely by an operator chain (no explicit layout/annotations), so
1584    /// it can be flattened into a longer chain of the same operator.
1585    fn is_plain(&self) -> bool {
1586        self.ncol.is_none()
1587            && self.nrow.is_none()
1588            && self.widths.is_empty()
1589            && self.heights.is_empty()
1590            && self.title.is_none()
1591            && self.subtitle.is_none()
1592            && self.caption.is_none()
1593            && self.tags.is_none()
1594            && !self.collect_legends
1595            && self.legend_bottom_share.is_none()
1596            && self.axis_collect == AxisCollect::Keep
1597    }
1598}
1599
1600macro_rules! grid_ops {
1601    ($lhs:ty, $rhs:ty) => {
1602        /// `a | b`: place side by side (patchwork).
1603        impl BitOr<$rhs> for $lhs {
1604            type Output = PlotGrid;
1605            fn bitor(self, rhs: $rhs) -> PlotGrid {
1606                join(Op::Row, self.into(), rhs.into())
1607            }
1608        }
1609        /// `a / b`: stack vertically (patchwork).
1610        impl Div<$rhs> for $lhs {
1611            type Output = PlotGrid;
1612            fn div(self, rhs: $rhs) -> PlotGrid {
1613                join(Op::Col, self.into(), rhs.into())
1614            }
1615        }
1616    };
1617}
1618
1619grid_ops!(GGPlot, GGPlot);
1620grid_ops!(GGPlot, PlotGrid);
1621grid_ops!(PlotGrid, GGPlot);
1622grid_ops!(PlotGrid, PlotGrid);
1623grid_ops!(GGPlot, GridElement);
1624grid_ops!(GridElement, GGPlot);
1625grid_ops!(PlotGrid, GridElement);
1626grid_ops!(GridElement, PlotGrid);
1627
1628#[cfg(test)]
1629mod tests {
1630    use super::*;
1631
1632    #[test]
1633    fn tag_sequences() {
1634        let l: Vec<String> = (0..28).map(|i| TagLevels::Lower.tag(i)).collect();
1635        assert_eq!(&l[..3], &["a", "b", "c"]);
1636        assert_eq!(l[25], "z");
1637        assert_eq!(l[26], "aa");
1638        assert_eq!(l[27], "ab");
1639        assert_eq!(TagLevels::Upper.tag(1), "B");
1640        assert_eq!(TagLevels::Numeric.tag(9), "10");
1641        assert_eq!(TagLevels::LowerRoman.tag(3), "iv");
1642        assert_eq!(TagLevels::UpperRoman.tag(13), "XIV");
1643        assert_eq!(TagLevels::Custom(vec!["x".into()]).tag(1), "2");
1644    }
1645
1646    #[test]
1647    fn tracks_are_relative_and_robust() {
1648        let t = tracks(&[2.0, 1.0], 2, 300.0, 0.0);
1649        assert_eq!(t, vec![(0.0, 200.0), (200.0, 100.0)]);
1650        let t = tracks(&[f64::NAN, -1.0], 2, 100.0, 10.0);
1651        assert_eq!(t, vec![(0.0, 45.0), (55.0, 45.0)]);
1652        let t = tracks(&[], 3, 30.0, 100.0);
1653        assert!(t.iter().all(|(p, s)| p.is_finite() && *s == 0.0));
1654    }
1655
1656    /// `Measure` (the legend bbox pre-pass) forwards every `DrawBackend`
1657    /// method to its inner backend and grows its tracked extent to include
1658    /// a quadratic curve's control points — not just straight lines/shapes.
1659    #[test]
1660    fn measure_forwards_draw_quadratic_and_mark_metadata_and_grows_its_extent() {
1661        let pa = Rect {
1662            x: 0.0,
1663            y: 0.0,
1664            width: 100.0,
1665            height: 100.0,
1666        };
1667        let mut backend = SvgBackend::new(100, 100, pa.clone());
1668        backend.set_formatted_values(true);
1669        let mut meas = Measure::new(&mut backend, pa);
1670
1671        assert!(meas.extent().is_none());
1672        // B2-4: Measure forwards the formatted-values query/setters too.
1673        assert!(meas.wants_formatted_values());
1674        meas.set_mark_geom(Some("line"));
1675        meas.set_mark_part(Some("arrow"));
1676        meas.set_mark_attrs(vec![("data-x".to_string(), "v".to_string())]);
1677        meas.set_mark_fvalue(Some("1,234.5".to_string()));
1678        meas.set_mark_fx(Some("2026-10-12".to_string()));
1679        meas.draw_quadratic(
1680            (10.0, 20.0),
1681            (90.0, 5.0),
1682            (50.0, 80.0),
1683            &LineStyle::default(),
1684        )
1685        .unwrap();
1686        meas.set_mark_geom(None);
1687        meas.set_mark_part(None);
1688        meas.set_mark_attrs(Vec::new());
1689        meas.set_mark_fvalue(None);
1690        meas.set_mark_fx(None);
1691
1692        let (x0, y0, x1, y1) = meas.extent().expect("extent after drawing");
1693        assert_eq!((x0, y0, x1, y1), (10.0, 5.0, 90.0, 80.0));
1694
1695        // The marks actually reached the inner SvgBackend (data-part, the
1696        // data_attr, the mark class and the formatted values all appear in
1697        // its output).
1698        let svg = backend.finish();
1699        assert!(svg.contains("data-part=\"arrow\""));
1700        assert!(svg.contains("data-x=\"v\""));
1701        assert!(svg.contains("gg-arrow"));
1702        assert!(svg.contains("data-fvalue=\"1,234.5\""));
1703        assert!(svg.contains("data-fx=\"2026-10-12\""));
1704    }
1705
1706    /// #170 / review #167 finding 4: `Measure::draw_text_with_title` must
1707    /// forward the `<title>` to its inner `SvgBackend`, not fall through
1708    /// to `DrawBackend::draw_text_with_title`'s default impl (which calls
1709    /// `self.draw_text`, and `Measure::draw_text` only ever calls
1710    /// `inner.draw_text` — dropping the title). This only matters once a
1711    /// `<title>` is actually passed through a `Measure`-wrapped backend
1712    /// (today that's `compose::capture_legend`'s collected-legend path, a
1713    /// `PlotGrid`'s B3-2 label truncation doesn't run yet, so the bug is
1714    /// latent rather than reachable from `PlotGrid`'s own public API —
1715    /// hence this unit test against `Measure` directly).
1716    #[test]
1717    fn measure_forwards_the_title_to_its_inner_backend() {
1718        let pa = Rect {
1719            x: 0.0,
1720            y: 0.0,
1721            width: 100.0,
1722            height: 100.0,
1723        };
1724        let mut backend = SvgBackend::new(100, 100, pa.clone());
1725        let mut meas = Measure::new(&mut backend, pa);
1726        meas.draw_text_with_title(
1727            "Grp…",
1728            (10.0, 20.0),
1729            &TextStyle {
1730                size: 12.0,
1731                anchor: TextAnchor::Start,
1732                ..Default::default()
1733            },
1734            Some("Group 01"),
1735        )
1736        .unwrap();
1737        let svg = backend.finish();
1738        assert!(
1739            svg.contains("<title>Group 01</title>"),
1740            "the full label should reach the inner backend's <title>: {svg}"
1741        );
1742    }
1743}