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>`. Returns the combined SVG string.
68pub fn ggarrange(
69    plots: Vec<GGPlot>,
70    ncol: usize,
71    cell_w: u32,
72    cell_h: u32,
73) -> Result<String, GGError> {
74    let n = plots.len();
75    let ncol = ncol.max(1);
76    let nrow = n.div_ceil(ncol);
77    let total_w = ncol as u32 * cell_w;
78    let total_h = nrow.max(1) as u32 * cell_h;
79
80    let mut children = String::new();
81    for (i, plot) in plots.into_iter().enumerate() {
82        let inner = plot.render_svg_native_with_size(cell_w, cell_h)?;
83        let x = (i % ncol) as u32 * cell_w;
84        let y = (i / ncol) as u32 * cell_h;
85        // Turn each child's root `<svg …>` into a positioned nested `<svg x y …>`;
86        // it keeps its own viewBox/width/height so it fills exactly its cell.
87        let positioned = inner.replacen("<svg ", &format!("<svg x=\"{x}\" y=\"{y}\" "), 1);
88        children.push_str(&positioned);
89    }
90
91    Ok(format!(
92        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_w}\" height=\"{total_h}\" \
93         viewBox=\"0 0 {total_w} {total_h}\">{children}</svg>"
94    ))
95}
96
97/// [`ggarrange`] that writes the combined SVG to `path`.
98pub fn ggarrange_save(
99    plots: Vec<GGPlot>,
100    ncol: usize,
101    cell_w: u32,
102    cell_h: u32,
103    path: &str,
104) -> Result<(), GGError> {
105    let svg = ggarrange(plots, ncol, cell_w, cell_h)?;
106    std::fs::write(path, svg).map_err(GGError::Io)
107}
108
109/// [`ggarrange`] rendered as a single PNG. Each plot is rasterised on its own
110/// (via the plotters bitmap backend) and composited into an `ncol`-wide grid.
111/// Returns the encoded PNG bytes.
112#[cfg(not(target_arch = "wasm32"))]
113pub fn ggarrange_png(
114    plots: Vec<GGPlot>,
115    ncol: usize,
116    cell_w: u32,
117    cell_h: u32,
118) -> Result<Vec<u8>, GGError> {
119    use crate::render::RenderError;
120    let n = plots.len();
121    let ncol = ncol.max(1);
122    let nrow = n.div_ceil(ncol).max(1);
123    let mut canvas = image::RgbaImage::from_pixel(
124        ncol as u32 * cell_w,
125        nrow as u32 * cell_h,
126        image::Rgba([255, 255, 255, 255]),
127    );
128    for (i, plot) in plots.into_iter().enumerate() {
129        let png = plot.render_png_with_size(cell_w, cell_h)?;
130        let cell = image::load_from_memory(&png)
131            .map_err(|e| GGError::Render(RenderError::BackendError(format!("decode: {e}"))))?
132            .to_rgba8();
133        let x = ((i % ncol) as u32 * cell_w) as i64;
134        let y = ((i / ncol) as u32 * cell_h) as i64;
135        image::imageops::overlay(&mut canvas, &cell, x, y);
136    }
137    let mut out = std::io::Cursor::new(Vec::new());
138    canvas
139        .write_to(&mut out, image::ImageOutputFormat::Png)
140        .map_err(|e| GGError::Render(RenderError::BackendError(format!("encode: {e}"))))?;
141    Ok(out.into_inner())
142}
143
144/// [`ggarrange_png`] that writes the composited PNG to `path`.
145#[cfg(not(target_arch = "wasm32"))]
146pub fn ggarrange_save_png(
147    plots: Vec<GGPlot>,
148    ncol: usize,
149    cell_w: u32,
150    cell_h: u32,
151    path: &str,
152) -> Result<(), GGError> {
153    let png = ggarrange_png(plots, ncol, cell_w, cell_h)?;
154    std::fs::write(path, png).map_err(GGError::Io)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::data::Value;
161
162    fn xy() -> Vec<(String, Vec<Value>)> {
163        let x: Vec<Value> = (0..12).map(|i| Value::Float(i as f64)).collect();
164        let y: Vec<Value> = (0..12).map(|i| Value::Float((i as f64).sin())).collect();
165        let g: Vec<Value> = (0..12)
166            .map(|i| Value::Str(["a", "b"][i % 2].to_string()))
167            .collect();
168        vec![
169            ("x".to_string(), x),
170            ("y".to_string(), y),
171            ("g".to_string(), g),
172        ]
173    }
174
175    #[test]
176    fn constructors_build_and_render() {
177        assert!(ggscatter(xy(), "x", "y", Some("g")).render_svg().is_ok());
178        assert!(ggline(xy(), "x", "y", None).render_svg().is_ok());
179        assert!(ggboxplot(xy(), "g", "y", Some("g")).render_svg().is_ok());
180        assert!(ggviolin(xy(), "g", "y", Some("g")).render_svg().is_ok());
181        assert!(gghistogram(xy(), "y", None).render_svg().is_ok());
182        assert!(ggdensity(xy(), "y", Some("g")).render_svg().is_ok());
183    }
184
185    #[test]
186    fn ggarrange_composes_a_grid() {
187        let plots = vec![
188            ggscatter(xy(), "x", "y", None),
189            ggline(xy(), "x", "y", None),
190            ggboxplot(xy(), "g", "y", Some("g")),
191            ggdensity(xy(), "y", None),
192        ];
193        let svg = ggarrange(plots, 2, 300, 220).expect("arrange");
194        // One outer document sized 2×2 cells, with four nested <svg> children.
195        assert!(svg.contains("width=\"600\" height=\"440\""), "outer size");
196        assert_eq!(svg.matches("<svg ").count(), 5, "outer + 4 nested svgs");
197        // The children are positioned into the four cells.
198        assert!(svg.contains("x=\"0\" y=\"0\""));
199        assert!(svg.contains("x=\"300\" y=\"0\""));
200        assert!(svg.contains("x=\"0\" y=\"220\""));
201        assert!(svg.contains("x=\"300\" y=\"220\""));
202    }
203
204    #[test]
205    #[cfg(not(target_arch = "wasm32"))]
206    fn ggarrange_png_composes_a_grid() {
207        let plots = vec![
208            ggscatter(xy(), "x", "y", None),
209            ggline(xy(), "x", "y", None),
210            ggboxplot(xy(), "g", "y", Some("g")),
211        ];
212        let png = ggarrange_png(plots, 2, 200, 160).expect("png");
213        // Valid PNG signature and non-trivial size.
214        assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
215        assert!(png.len() > 1000);
216    }
217
218    #[test]
219    fn constructors_are_chainable() {
220        // The returned GGPlot keeps chaining (here: a title + a manual save-less
221        // render), proving these are ordinary builders.
222        let svg = ggscatter(xy(), "x", "y", None)
223            .title("chained")
224            .render_svg()
225            .unwrap();
226        assert!(svg.contains("chained"));
227    }
228}