Skip to main content

plotkit_core/
figure.rs

1//! The top-level Figure container.
2//!
3//! A [`Figure`] owns one or more [`Axes`] (subplots) and orchestrates the
4//! full rendering pipeline: filling the figure background, computing the
5//! subplot grid layout, drawing the optional super-title, and delegating
6//! per-axes rendering.
7
8use crate::axes::Axes;
9use crate::error::Result;
10use crate::layout;
11use crate::primitives::{Affine, HAlign, Paint, Path, Point, Rect, TextStyle, VAlign};
12use crate::renderer::Renderer;
13use crate::theme::Theme;
14
15// ---------------------------------------------------------------------------
16// Constants
17// ---------------------------------------------------------------------------
18
19/// Default figure width in pixels.
20const DEFAULT_WIDTH: u32 = 800;
21
22/// Default figure height in pixels.
23const DEFAULT_HEIGHT: u32 = 600;
24
25/// Vertical space (in pixels) reserved for the suptitle when present.
26const SUPTITLE_RESERVED_HEIGHT: f64 = 30.0;
27
28/// Default outer margin (in pixels) around the subplot grid.
29const DEFAULT_MARGIN: f64 = 20.0;
30
31/// Default gap (in pixels) between subplot cells.
32const DEFAULT_GAP: f64 = 15.0;
33
34// ---------------------------------------------------------------------------
35// Figure
36// ---------------------------------------------------------------------------
37
38/// A figure is the top-level container for one or more axes (subplots).
39///
40/// The figure owns all axes, holds the overall dimensions, an optional
41/// super-title, and the visual theme. It implements the rendering pipeline
42/// described in `ARCHITECTURE.md`:
43///
44/// 1. Fill figure background.
45/// 2. Draw suptitle if set.
46/// 3. Compute subplot layout rectangles.
47/// 4. For each axes: delegate rendering with its allocated rectangle and theme.
48///
49/// # Ownership model (ADR-003)
50///
51/// `Figure` owns `Vec<Axes>` directly -- no `Rc`, no `RefCell`.
52/// [`add_subplot`](Figure::add_subplot) returns `&mut Axes`, and the borrow
53/// checker enforces single-axes mutation at compile time.
54///
55/// # Examples
56///
57/// ```no_run
58/// use plotkit_core::figure::Figure;
59///
60/// let mut fig = Figure::new();
61/// let ax = fig.add_subplot(1, 1, 1);
62/// // ax.plot(...)?;
63/// ```
64#[derive(Debug)]
65pub struct Figure {
66    /// The subplot axes owned by this figure, stored in insertion order.
67    axes: Vec<Axes>,
68    /// Width of the output image in pixels.
69    width: u32,
70    /// Height of the output image in pixels.
71    height: u32,
72    /// Optional overall title displayed above all subplots.
73    suptitle: Option<String>,
74    /// The visual theme applied to the figure and inherited by axes.
75    theme: Theme,
76    /// Subplot grid dimensions `(nrows, ncols)`, set by `add_subplot`.
77    subplot_grid: Option<(usize, usize)>,
78}
79
80impl Figure {
81    /// Creates a new figure with default dimensions (800 x 600 pixels).
82    pub fn new() -> Self {
83        Self {
84            axes: Vec::new(),
85            width: DEFAULT_WIDTH,
86            height: DEFAULT_HEIGHT,
87            suptitle: None,
88            theme: Theme::default(),
89            subplot_grid: None,
90        }
91    }
92
93    /// Creates a new figure with the specified dimensions in pixels.
94    pub fn with_size(width: u32, height: u32) -> Self {
95        Self {
96            axes: Vec::new(),
97            width,
98            height,
99            suptitle: None,
100            theme: Theme::default(),
101            subplot_grid: None,
102        }
103    }
104
105    /// Returns the figure width in pixels.
106    pub fn width(&self) -> u32 {
107        self.width
108    }
109
110    /// Returns the figure height in pixels.
111    pub fn height(&self) -> u32 {
112        self.height
113    }
114
115    /// Adds a subplot and returns a mutable reference to it.
116    ///
117    /// Uses 1-based indexing in matplotlib style: `(nrows, ncols, index)`.
118    /// The index counts across rows first (row-major), starting at 1.
119    ///
120    /// If a subplot already exists at the given `index`, the existing axes
121    /// is returned without creating a duplicate. Otherwise a new [`Axes`]
122    /// is created, appended to the figure's axes list, and returned.
123    ///
124    /// # Panics
125    ///
126    /// Panics if `nrows`, `ncols`, or `index` is zero, or if `index` exceeds
127    /// `nrows * ncols`.
128    ///
129    /// # Examples
130    ///
131    /// ```no_run
132    /// use plotkit_core::figure::Figure;
133    ///
134    /// let mut fig = Figure::new();
135    /// let ax = fig.add_subplot(2, 2, 1); // top-left of a 2x2 grid
136    /// ```
137    pub fn add_subplot(&mut self, nrows: usize, ncols: usize, index: usize) -> &mut Axes {
138        assert!(nrows > 0, "nrows must be at least 1");
139        assert!(ncols > 0, "ncols must be at least 1");
140        assert!(index >= 1, "subplot index is 1-based; got 0");
141        assert!(
142            index <= nrows * ncols,
143            "subplot index {index} exceeds grid size {nrows}x{ncols} = {}",
144            nrows * ncols
145        );
146
147        // Store (or validate) the grid dimensions. If the grid was already set
148        // with different dimensions, update to the latest request -- this
149        // mirrors matplotlib's behaviour where later add_subplot calls can
150        // redefine the grid.
151        self.subplot_grid = Some((nrows, ncols));
152
153        // Convert 1-based index to 0-based for internal storage.
154        let zero_index = index - 1;
155
156        // Ensure the internal axes vec is large enough. If the user skips
157        // indices (e.g. add_subplot(2,2,3) without adding 1 and 2 first) we
158        // pad with default Axes so that positional indexing is consistent.
159        while self.axes.len() <= zero_index {
160            self.axes.push(Axes::new());
161        }
162
163        &mut self.axes[zero_index]
164    }
165
166    /// Sets the overall figure title (super-title), displayed above all
167    /// subplots.
168    ///
169    /// Returns `&mut Self` for builder-style chaining.
170    pub fn suptitle(&mut self, title: &str) -> &mut Self {
171        self.suptitle = Some(title.to_string());
172        self
173    }
174
175    /// Sets the visual theme for the entire figure.
176    ///
177    /// The theme is inherited by all axes during rendering unless an
178    /// individual axes has its own override.
179    ///
180    /// Returns `&mut Self` for builder-style chaining.
181    pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
182        self.theme = theme;
183        self
184    }
185
186    /// Returns a reference to the figure's theme.
187    pub fn theme(&self) -> &Theme {
188        &self.theme
189    }
190
191    /// Returns mutable access to the axes at `index` (0-based).
192    ///
193    /// Returns `None` if `index` is out of bounds.
194    pub fn axes_mut(&mut self, index: usize) -> Option<&mut Axes> {
195        self.axes.get_mut(index)
196    }
197
198    /// Returns a shared reference to the axes at `index` (0-based).
199    ///
200    /// Returns `None` if `index` is out of bounds.
201    pub fn axes(&self, index: usize) -> Option<&Axes> {
202        self.axes.get(index)
203    }
204
205    /// Returns the number of axes (subplots) in this figure.
206    pub fn num_axes(&self) -> usize {
207        self.axes.len()
208    }
209
210    /// Creates a new figure with an `nrows × ncols` subplot grid.
211    ///
212    /// Axes are added in row-major order (index 0 = top-left). Access them
213    /// via `fig.axes_mut(index)` where index goes from 0 to `nrows*ncols - 1`.
214    ///
215    /// # Panics
216    ///
217    /// Panics if `nrows` or `ncols` is zero.
218    pub fn subplots(nrows: usize, ncols: usize) -> Self {
219        assert!(nrows > 0 && ncols > 0, "subplots: nrows and ncols must be > 0");
220        let mut fig = Self::new();
221        for i in 1..=(nrows * ncols) {
222            fig.add_subplot(nrows, ncols, i);
223        }
224        fig
225    }
226
227    /// Creates a new figure with an `nrows × ncols` subplot grid and custom size.
228    ///
229    /// # Panics
230    ///
231    /// Panics if `nrows` or `ncols` is zero.
232    pub fn subplots_with_size(nrows: usize, ncols: usize, width: u32, height: u32) -> Self {
233        assert!(nrows > 0 && ncols > 0, "subplots: nrows and ncols must be > 0");
234        let mut fig = Self::with_size(width, height);
235        for i in 1..=(nrows * ncols) {
236            fig.add_subplot(nrows, ncols, i);
237        }
238        fig
239    }
240
241    /// 2D indexing into the subplot grid.
242    ///
243    /// Returns `axes_mut(row * ncols + col)`, or `None` if out of bounds.
244    pub fn axes_grid(&mut self, row: usize, col: usize, ncols: usize) -> Option<&mut Axes> {
245        self.axes_mut(row * ncols + col)
246    }
247
248    // -----------------------------------------------------------------------
249    // Rendering
250    // -----------------------------------------------------------------------
251
252    /// Renders the figure using the given renderer.
253    ///
254    /// This is the core rendering pipeline (per `ARCHITECTURE.md`):
255    ///
256    /// 1. Fill figure background with the theme's `figure_background` color.
257    /// 2. Draw suptitle if one has been set.
258    /// 3. Compute the subplot grid layout, producing one [`Rect`] per axes.
259    /// 4. For each axes, delegate to [`Axes::render`] with its assigned
260    ///    rectangle and the figure theme.
261    pub fn render(&self, renderer: &mut impl Renderer) {
262        let (w, h) = renderer.size();
263        let fw = w as f64;
264        let fh = h as f64;
265        let theme = &self.theme;
266
267        // ----- 1. Fill figure background -----------------------------------
268        let bg_path = Path::rect(Rect::new(0.0, 0.0, fw, fh));
269        renderer.fill_path(
270            &bg_path,
271            &Paint::new(theme.figure_background),
272            Affine::IDENTITY,
273        );
274
275        // ----- 2. Draw suptitle if set -------------------------------------
276        let top_offset = if let Some(ref title) = self.suptitle {
277            let style = TextStyle {
278                size: theme.title_size + 2.0, // suptitle slightly larger than axes title
279                color: theme.text_color,
280                weight: theme.title_weight,
281                family: theme.font_family.clone(),
282                halign: HAlign::Center,
283                valign: VAlign::Top,
284            };
285
286            let text_pos = Point::new(fw / 2.0, DEFAULT_MARGIN * 0.5);
287            renderer.draw_text(title, text_pos, &style, Affine::IDENTITY);
288
289            SUPTITLE_RESERVED_HEIGHT
290        } else {
291            0.0
292        };
293
294        // ----- 3. Compute subplot layout -----------------------------------
295        // Reduce the available height by the suptitle offset so that subplots
296        // are laid out below the suptitle instead of overlapping it.
297        let grid = self.subplot_grid.unwrap_or((1, 1));
298        let rects = layout::compute_subplot_rects(
299            fw,
300            fh - top_offset,
301            grid.0,
302            grid.1,
303            DEFAULT_MARGIN,
304            DEFAULT_GAP,
305        )
306        .into_iter()
307        .map(|mut r| {
308            r.y += top_offset;
309            r
310        })
311        .collect::<Vec<_>>();
312
313        // ----- 4. Render each axes -----------------------------------------
314        for (i, axes) in self.axes.iter().enumerate() {
315            if let Some(rect) = rects.get(i) {
316                axes.render(renderer, *rect, theme);
317            }
318        }
319    }
320
321    /// Renders the figure using the given renderer and returns the encoded
322    /// output bytes (PNG, SVG, PDF, etc., depending on the renderer).
323    ///
324    /// This convenience method calls [`render`](Figure::render) and then
325    /// [`Renderer::finalize`] to produce the final byte output.
326    pub fn render_to<R: Renderer>(&self, mut renderer: R) -> Vec<u8> {
327        self.render(&mut renderer);
328        renderer.finalize()
329    }
330
331    /// Saves the figure to a file using the provided renderer.
332    ///
333    /// The renderer determines the output format. The encoded bytes are
334    /// written to `path` atomically via [`std::fs::write`].
335    pub fn save_with<R: Renderer>(&self, renderer: R, path: impl AsRef<std::path::Path>) -> Result<()> {
336        let bytes = self.render_to(renderer);
337        std::fs::write(path, bytes)?;
338        Ok(())
339    }
340}
341
342impl Default for Figure {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::primitives::Color;
356
357    #[test]
358    fn new_figure_has_default_dimensions() {
359        let fig = Figure::new();
360        assert_eq!(fig.width(), DEFAULT_WIDTH);
361        assert_eq!(fig.height(), DEFAULT_HEIGHT);
362    }
363
364    #[test]
365    fn with_size_sets_dimensions() {
366        let fig = Figure::with_size(1024, 768);
367        assert_eq!(fig.width(), 1024);
368        assert_eq!(fig.height(), 768);
369    }
370
371    #[test]
372    fn default_figure_has_no_axes() {
373        let fig = Figure::new();
374        assert_eq!(fig.num_axes(), 0);
375    }
376
377    #[test]
378    fn add_subplot_creates_axes() {
379        let mut fig = Figure::new();
380        let _ax = fig.add_subplot(1, 1, 1);
381        assert_eq!(fig.num_axes(), 1);
382    }
383
384    #[test]
385    fn add_subplot_returns_same_axes_on_repeat() {
386        let mut fig = Figure::new();
387        fig.add_subplot(2, 2, 1);
388        fig.add_subplot(2, 2, 1); // same index, should not duplicate
389        assert_eq!(fig.num_axes(), 1);
390    }
391
392    #[test]
393    fn add_subplot_pads_for_skipped_indices() {
394        let mut fig = Figure::new();
395        fig.add_subplot(2, 2, 3); // skip indices 1 and 2
396        assert_eq!(fig.num_axes(), 3); // indices 0, 1, 2 all exist
397    }
398
399    #[test]
400    #[should_panic(expected = "nrows must be at least 1")]
401    fn add_subplot_panics_on_zero_rows() {
402        let mut fig = Figure::new();
403        fig.add_subplot(0, 1, 1);
404    }
405
406    #[test]
407    #[should_panic(expected = "ncols must be at least 1")]
408    fn add_subplot_panics_on_zero_cols() {
409        let mut fig = Figure::new();
410        fig.add_subplot(1, 0, 1);
411    }
412
413    #[test]
414    #[should_panic(expected = "subplot index is 1-based")]
415    fn add_subplot_panics_on_zero_index() {
416        let mut fig = Figure::new();
417        fig.add_subplot(1, 1, 0);
418    }
419
420    #[test]
421    #[should_panic(expected = "subplot index 5 exceeds grid size")]
422    fn add_subplot_panics_on_index_out_of_range() {
423        let mut fig = Figure::new();
424        fig.add_subplot(2, 2, 5);
425    }
426
427    #[test]
428    fn suptitle_sets_title() {
429        let mut fig = Figure::new();
430        fig.suptitle("My Figure");
431        assert_eq!(fig.suptitle, Some("My Figure".to_string()));
432    }
433
434    #[test]
435    fn suptitle_returns_self_for_chaining() {
436        let mut fig = Figure::new();
437        fig.suptitle("Title 1").suptitle("Title 2");
438        assert_eq!(fig.suptitle, Some("Title 2".to_string()));
439    }
440
441    #[test]
442    fn set_theme_updates_theme() {
443        let mut fig = Figure::new();
444        let dark = Theme::dark();
445        fig.set_theme(dark);
446        assert_eq!(fig.theme().figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
447    }
448
449    #[test]
450    fn theme_returns_reference() {
451        let fig = Figure::new();
452        assert_eq!(fig.theme().figure_background, Color::WHITE);
453    }
454
455    #[test]
456    fn axes_mut_returns_none_for_out_of_bounds() {
457        let mut fig = Figure::new();
458        assert!(fig.axes_mut(0).is_none());
459    }
460
461    #[test]
462    fn axes_mut_returns_some_for_valid_index() {
463        let mut fig = Figure::new();
464        fig.add_subplot(1, 1, 1);
465        assert!(fig.axes_mut(0).is_some());
466    }
467
468    #[test]
469    fn axes_returns_shared_reference() {
470        let mut fig = Figure::new();
471        fig.add_subplot(1, 1, 1);
472        assert!(fig.axes(0).is_some());
473        assert!(fig.axes(1).is_none());
474    }
475
476    #[test]
477    fn default_impl_matches_new() {
478        let from_new = Figure::new();
479        let from_default = Figure::default();
480        assert_eq!(from_new.width(), from_default.width());
481        assert_eq!(from_new.height(), from_default.height());
482        assert_eq!(from_new.num_axes(), from_default.num_axes());
483    }
484
485    #[test]
486    fn multiple_subplots_in_grid() {
487        let mut fig = Figure::new();
488        fig.add_subplot(2, 3, 1);
489        fig.add_subplot(2, 3, 4);
490        fig.add_subplot(2, 3, 6);
491        assert_eq!(fig.num_axes(), 6); // padded to fill up to index 6
492        assert_eq!(fig.subplot_grid, Some((2, 3)));
493    }
494}