Skip to main content

ggplot_rs/
ggpubr.rs

1//! ggpubr-style one-call plot constructors.
2//!
3//! Thin wrappers that build a [`GGPlot`] pre-configured with the right
4//! aesthetics, geom, and the publication [`theme_pubr`](crate::theme::presets::theme_pubr),
5//! mirroring R's `ggpubr::ggscatter` / `ggboxplot` / … Each returns a `GGPlot`
6//! so you can keep chaining (add `stat_cor`, palettes, labels, save, …).
7//!
8//! These are pure grammar — no statistics — so they're always available (they
9//! don't require the `ggpubr` feature, which only gates the stat annotations).
10
11use crate::aes::Aes;
12use crate::data::GGData;
13use crate::plot::{GGError, GGPlot};
14
15fn base(data: impl GGData, mut aes: Aes, group: Option<&str>, as_fill: bool) -> (GGPlot, Aes) {
16    if let Some(g) = group {
17        aes = if as_fill { aes.fill(g) } else { aes.color(g) };
18    }
19    (GGPlot::new(data), aes)
20}
21
22/// Publication scatter plot (`ggpubr::ggscatter`). `color` optionally maps a
23/// grouping column to point colour.
24pub fn ggscatter(data: impl GGData, x: &str, y: &str, color: Option<&str>) -> GGPlot {
25    let (plot, aes) = base(data, Aes::new().x(x).y(y), color, false);
26    plot.aes(aes).geom_point().theme_pubr()
27}
28
29/// Publication line plot (`ggpubr::ggline`). `color` optionally maps a grouping
30/// column to line colour.
31pub fn ggline(data: impl GGData, x: &str, y: &str, color: Option<&str>) -> GGPlot {
32    let (plot, aes) = base(data, Aes::new().x(x).y(y), color, false);
33    plot.aes(aes).geom_line().theme_pubr()
34}
35
36/// Publication box plot (`ggpubr::ggboxplot`). `fill` optionally maps a grouping
37/// column to box fill.
38pub fn ggboxplot(data: impl GGData, x: &str, y: &str, fill: Option<&str>) -> GGPlot {
39    let (plot, aes) = base(data, Aes::new().x(x).y(y), fill, true);
40    plot.aes(aes).geom_boxplot().theme_pubr()
41}
42
43/// Publication violin plot (`ggpubr::ggviolin`). `fill` optionally maps a
44/// grouping column to violin fill.
45pub fn ggviolin(data: impl GGData, x: &str, y: &str, fill: Option<&str>) -> GGPlot {
46    let (plot, aes) = base(data, Aes::new().x(x).y(y), fill, true);
47    plot.aes(aes).geom_violin().theme_pubr()
48}
49
50/// Publication histogram (`ggpubr::gghistogram`) of a single variable `x`.
51/// `fill` optionally maps a grouping column to bar fill.
52pub fn gghistogram(data: impl GGData, x: &str, fill: Option<&str>) -> GGPlot {
53    let (plot, aes) = base(data, Aes::new().x(x), fill, true);
54    plot.aes(aes).geom_histogram().theme_pubr()
55}
56
57/// Publication density plot (`ggpubr::ggdensity`) of a single variable `x`.
58/// `color` optionally maps a grouping column to line colour.
59pub fn ggdensity(data: impl GGData, x: &str, color: Option<&str>) -> GGPlot {
60    let (plot, aes) = base(data, Aes::new().x(x), color, false);
61    plot.aes(aes).geom_density().theme_pubr()
62}
63
64/// Arrange several plots in a grid, composed into a single SVG document
65/// (`ggpubr::ggarrange`). Plots fill row-major across `ncol` columns; each
66/// occupies a `cell_w` × `cell_h` cell and is embedded as a positioned nested
67/// `<svg>` carrying `data-panel="<index>"`. Returns the combined SVG string.
68///
69/// This is a thin wrapper over [`PlotGrid`](crate::compose::PlotGrid) — use
70/// it directly for titles, panel tags, collected legends and relative sizes.
71pub fn ggarrange(
72    plots: Vec<GGPlot>,
73    ncol: usize,
74    cell_w: u32,
75    cell_h: u32,
76) -> Result<String, GGError> {
77    let n = plots.len();
78    let ncol = ncol.max(1);
79    let nrow = n.div_ceil(ncol).max(1);
80    crate::compose::PlotGrid::new()
81        .add_all(plots)
82        .ncol(ncol)
83        .render_svg_native_with_size(
84            (ncol as u32).saturating_mul(cell_w),
85            (nrow as u32).saturating_mul(cell_h),
86        )
87}
88
89/// [`ggarrange`] that writes the combined SVG to `path`.
90pub fn ggarrange_save(
91    plots: Vec<GGPlot>,
92    ncol: usize,
93    cell_w: u32,
94    cell_h: u32,
95    path: &str,
96) -> Result<(), GGError> {
97    let svg = ggarrange(plots, ncol, cell_w, cell_h)?;
98    std::fs::write(path, svg).map_err(GGError::Io)
99}
100
101/// [`ggarrange`] rendered as a single PNG. Each plot is rasterised on its own
102/// (via the plotters bitmap backend) and composited into an `ncol`-wide grid.
103/// Returns the encoded PNG bytes. Requires the `plotters` feature (default).
104#[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
105pub fn ggarrange_png(
106    plots: Vec<GGPlot>,
107    ncol: usize,
108    cell_w: u32,
109    cell_h: u32,
110) -> Result<Vec<u8>, GGError> {
111    use crate::render::RenderError;
112    let n = plots.len();
113    let ncol = ncol.max(1);
114    let nrow = n.div_ceil(ncol).max(1);
115    let mut canvas = image::RgbaImage::from_pixel(
116        ncol as u32 * cell_w,
117        nrow as u32 * cell_h,
118        image::Rgba([255, 255, 255, 255]),
119    );
120    for (i, plot) in plots.into_iter().enumerate() {
121        let png = plot.render_png_with_size(cell_w, cell_h)?;
122        let cell = image::load_from_memory(&png)
123            .map_err(|e| GGError::Render(RenderError::BackendError(format!("decode: {e}"))))?
124            .to_rgba8();
125        let x = ((i % ncol) as u32 * cell_w) as i64;
126        let y = ((i / ncol) as u32 * cell_h) as i64;
127        image::imageops::overlay(&mut canvas, &cell, x, y);
128    }
129    let mut out = std::io::Cursor::new(Vec::new());
130    canvas
131        .write_to(&mut out, image::ImageOutputFormat::Png)
132        .map_err(|e| GGError::Render(RenderError::BackendError(format!("encode: {e}"))))?;
133    Ok(out.into_inner())
134}
135
136/// [`ggarrange_png`] that writes the composited PNG to `path`.
137#[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
138pub fn ggarrange_save_png(
139    plots: Vec<GGPlot>,
140    ncol: usize,
141    cell_w: u32,
142    cell_h: u32,
143    path: &str,
144) -> Result<(), GGError> {
145    let png = ggarrange_png(plots, ncol, cell_w, cell_h)?;
146    std::fs::write(path, png).map_err(GGError::Io)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::data::Value;
153
154    fn xy() -> Vec<(String, Vec<Value>)> {
155        let x: Vec<Value> = (0..12).map(|i| Value::Float(i as f64)).collect();
156        let y: Vec<Value> = (0..12).map(|i| Value::Float((i as f64).sin())).collect();
157        let g: Vec<Value> = (0..12)
158            .map(|i| Value::Str(["a", "b"][i % 2].to_string()))
159            .collect();
160        vec![
161            ("x".to_string(), x),
162            ("y".to_string(), y),
163            ("g".to_string(), g),
164        ]
165    }
166
167    #[test]
168    fn constructors_build_and_render() {
169        assert!(ggscatter(xy(), "x", "y", Some("g"))
170            .render_svg_native()
171            .is_ok());
172        assert!(ggline(xy(), "x", "y", None).render_svg_native().is_ok());
173        assert!(ggboxplot(xy(), "g", "y", Some("g"))
174            .render_svg_native()
175            .is_ok());
176        assert!(ggviolin(xy(), "g", "y", Some("g"))
177            .render_svg_native()
178            .is_ok());
179        assert!(gghistogram(xy(), "y", None).render_svg_native().is_ok());
180        assert!(ggdensity(xy(), "y", Some("g")).render_svg_native().is_ok());
181    }
182
183    #[test]
184    fn ggarrange_composes_a_grid() {
185        let plots = vec![
186            ggscatter(xy(), "x", "y", None),
187            ggline(xy(), "x", "y", None),
188            ggboxplot(xy(), "g", "y", Some("g")),
189            ggdensity(xy(), "y", None),
190        ];
191        let svg = ggarrange(plots, 2, 300, 220).expect("arrange");
192        // One outer document sized 2×2 cells, with four nested <svg> children.
193        assert!(svg.contains("width=\"600\" height=\"440\""), "outer size");
194        assert_eq!(svg.matches("<svg ").count(), 5, "outer + 4 nested svgs");
195        // The children are positioned into the four cells.
196        assert!(svg.contains("x=\"0.00\" y=\"0.00\" width=\"300\" height=\"220\""));
197        assert!(svg.contains("x=\"300.00\" y=\"0.00\""));
198        assert!(svg.contains("x=\"0.00\" y=\"220.00\""));
199        assert!(svg.contains("x=\"300.00\" y=\"220.00\""));
200        assert!(svg.contains("data-panel=\"4\""));
201    }
202
203    #[test]
204    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
205    fn ggarrange_png_composes_a_grid() {
206        let plots = vec![
207            ggscatter(xy(), "x", "y", None),
208            ggline(xy(), "x", "y", None),
209            ggboxplot(xy(), "g", "y", Some("g")),
210        ];
211        let png = ggarrange_png(plots, 2, 200, 160).expect("png");
212        // Valid PNG signature and non-trivial size.
213        assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
214        assert!(png.len() > 1000);
215    }
216
217    #[test]
218    fn constructors_are_chainable() {
219        // The returned GGPlot keeps chaining (here: a title + a manual save-less
220        // render), proving these are ordinary builders.
221        let svg = ggscatter(xy(), "x", "y", None)
222            .title("chained")
223            .render_svg_native()
224            .unwrap();
225        assert!(svg.contains("chained"));
226    }
227}