Skip to main content

plotlars_plotly/subplot_grid/
mod.rs

1use bon::bon;
2use plotly::{layout::Layout as LayoutPlotly, Trace};
3use serde::ser::{Serialize, SerializeStruct, Serializer};
4use serde_json::Value;
5
6use plotlars_core::components::{Dimensions, Text};
7use plotlars_core::Plot;
8
9mod custom_legend;
10mod irregular;
11mod regular;
12mod shared;
13
14/// A structure representing a subplot grid layout.
15///
16/// The `SubplotGrid` struct facilitates the creation of multi-plot layouts arranged in a grid configuration.
17/// Plots are automatically arranged in rows and columns in row-major order (left-to-right, top-to-bottom).
18/// Each subplot retains its own title, axis labels, and legend, providing flexibility for displaying
19/// multiple related visualizations in a single figure.
20///
21/// # Features
22///
23/// - Automatic grid layout with configurable rows and columns
24/// - Individual subplot titles (extracted from plot titles)
25/// - Independent axis labels for each subplot
26/// - Configurable horizontal and vertical spacing
27/// - Overall figure title
28/// - Sparse grid support (fewer plots than grid capacity)
29///
30#[derive(Clone)]
31pub struct SubplotGrid {
32    traces: Vec<Box<dyn Trace + 'static>>,
33    layout: LayoutPlotly,
34    layout_json: Option<Value>,
35}
36
37impl Serialize for SubplotGrid {
38    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
39    where
40        S: Serializer,
41    {
42        let mut state = serializer.serialize_struct("SubplotGrid", 2)?;
43        state.serialize_field("traces", &self.traces)?;
44
45        if let Some(ref layout_json) = self.layout_json {
46            state.serialize_field("layout", layout_json)?;
47        } else {
48            state.serialize_field("layout", &self.layout)?;
49        }
50
51        state.end()
52    }
53}
54
55#[bon]
56impl SubplotGrid {
57    /// Creates a subplot grid layout.
58    ///
59    /// Arranges plots in a row * column grid with automatic positioning. Plots are placed
60    /// in row-major order (left-to-right, top-to-bottom). Each subplot retains its individual title
61    /// (from the plot's `plot_title`), axis labels, and legend.
62    ///
63    /// # Arguments
64    ///
65    /// * `plots` - Vector of plot references to arrange in the grid. Plots are positioned in row-major order.
66    /// * `rows` - Number of rows in the grid (default: 1).
67    /// * `cols` - Number of columns in the grid (default: 1).
68    /// * `title` - Overall title for the entire subplot figure (optional).
69    /// * `h_gap` - Horizontal spacing between columns (range: 0.0 to 1.0, default: 0.1).
70    /// * `v_gap` - Vertical spacing between rows (range: 0.0 to 1.0, default: 0.1).
71    ///
72    /// ![Example](https://imgur.com/q0K7cyP.png)
73    #[builder(on(String, into), on(Text, into), finish_fn = build)]
74    pub fn regular(
75        plots: Vec<&dyn Plot>,
76        rows: Option<usize>,
77        cols: Option<usize>,
78        title: Option<Text>,
79        h_gap: Option<f64>,
80        v_gap: Option<f64>,
81        dimensions: Option<&Dimensions>,
82    ) -> Self {
83        regular::build_regular(plots, rows, cols, title, h_gap, v_gap, None, dimensions)
84    }
85
86    /// Creates an irregular grid subplot layout with custom row/column spanning.
87    ///
88    /// Allows plots to span multiple rows and/or columns, enabling dashboard-style
89    /// layouts and asymmetric grid arrangements. Each plot explicitly specifies its
90    /// position and span.
91    ///
92    /// # Arguments
93    ///
94    /// * `plots` - Vector of tuples `(plot, row, col, rowspan, colspan)` where:
95    ///   - `plot`: Reference to the plot
96    ///   - `row`: Starting row (0-indexed)
97    ///   - `col`: Starting column (0-indexed)
98    ///   - `rowspan`: Number of rows to span (minimum 1)
99    ///   - `colspan`: Number of columns to span (minimum 1)
100    /// * `rows` - Total number of rows in the grid (default: 1).
101    /// * `cols` - Total number of columns in the grid (default: 1).
102    /// * `title` - Overall title for the subplot figure (optional).
103    /// * `h_gap` - Horizontal spacing between columns (range: 0.0 to 1.0, default: 0.1).
104    /// * `v_gap` - Vertical spacing between rows (range: 0.0 to 1.0, default: 0.1).
105    ///
106    /// ![Example](https://imgur.com/RvZwv3O.png)
107    #[builder(on(String, into), on(Text, into), finish_fn = build)]
108    pub fn irregular(
109        plots: Vec<(&dyn Plot, usize, usize, usize, usize)>,
110        rows: Option<usize>,
111        cols: Option<usize>,
112        title: Option<Text>,
113        h_gap: Option<f64>,
114        v_gap: Option<f64>,
115        dimensions: Option<&Dimensions>,
116    ) -> Self {
117        irregular::build_irregular(plots, rows, cols, title, h_gap, v_gap, dimensions)
118    }
119}
120
121// Manual PlotlyExt implementation for SubplotGrid.
122// SubplotGrid is a composite (not an IR-based plot), so it cannot implement core::Plot.
123// Instead it provides its own rendering methods.
124impl SubplotGrid {
125    /// Display the subplot grid in the default browser or Jupyter notebook.
126    pub fn plot(&self) {
127        use std::env;
128
129        match env::var("EVCXR_IS_RUNTIME") {
130            Ok(_) => {
131                let mut plotly_plot = plotly::Plot::new();
132                plotly_plot.set_layout(self.layout.clone());
133                for trace in self.traces.clone() {
134                    plotly_plot.add_trace(trace);
135                }
136                plotly_plot.evcxr_display();
137            }
138            _ => {
139                let html = self.to_html();
140                let dir = std::env::temp_dir();
141                let path = dir.join("plotlars_subplot_grid.html");
142                std::fs::write(&path, &html).expect("Failed to write HTML file");
143                crate::render::open_html_file(&path);
144            }
145        }
146    }
147
148    /// Write the subplot grid to an HTML file.
149    pub fn write_html(&self, path: impl Into<String>) {
150        let html = self.to_html();
151        std::fs::write(path.into(), html).expect("Failed to write HTML file");
152    }
153
154    /// Serialize the subplot grid to a JSON string.
155    pub fn to_json(&self) -> Result<String, serde_json::Error> {
156        let layout_val = self
157            .layout_json
158            .as_ref()
159            .cloned()
160            .unwrap_or_else(|| serde_json::to_value(&self.layout).unwrap_or(Value::Null));
161        let traces_json: Vec<Value> = self
162            .traces
163            .iter()
164            .map(|t| {
165                let s = t.to_json();
166                serde_json::from_str(&s).unwrap_or(Value::Null)
167            })
168            .collect();
169        let output = serde_json::json!({
170            "traces": traces_json,
171            "layout": layout_val,
172        });
173        serde_json::to_string(&output)
174    }
175
176    /// Render the subplot grid as a standalone HTML string.
177    pub fn to_html(&self) -> String {
178        let plot_json = self.to_json().unwrap();
179        let escaped_json = plot_json
180            .replace('\\', "\\\\")
181            .replace('\'', "\\'")
182            .replace('\n', "\\n")
183            .replace('\r', "\\r");
184        format!(
185            r#"<!DOCTYPE html>
186<html>
187<head>
188    <meta charset="utf-8" />
189    <script src="https://cdn.plot.ly/plotly-3.0.1.min.js"></script>
190</head>
191<body>
192    <div id="plotly-div" style="width:100%;height:100%;"></div>
193    <script type="text/javascript">
194        var plotData = JSON.parse('{}');
195        Plotly.newPlot('plotly-div', plotData.traces, plotData.layout, {{responsive: true}});
196    </script>
197</body>
198</html>"#,
199            escaped_json
200        )
201    }
202
203    /// Render the subplot grid as an inline HTML snippet (no DOCTYPE/head).
204    pub fn to_inline_html(&self, plot_div_id: Option<&str>) -> String {
205        let div_id = plot_div_id.unwrap_or("plotly-div");
206        let plot_json = self.to_json().unwrap();
207        format!(
208            r#"<div>
209<script src="https://cdn.plot.ly/plotly-3.0.1.min.js"></script>
210<div id="{div_id}" class="plotly-graph-div" style="height:100%; width:100%;"></div>
211<script type="text/javascript">
212  var plotData = {plot_json};
213  Plotly.newPlot("{div_id}", plotData.traces, plotData.layout, {{responsive: true}});
214</script>
215</div>"#,
216            div_id = div_id,
217            plot_json = plot_json
218        )
219    }
220}