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 // -----------------------------------------------------------------------
211 // Rendering
212 // -----------------------------------------------------------------------
213
214 /// Renders the figure using the given renderer.
215 ///
216 /// This is the core rendering pipeline (per `ARCHITECTURE.md`):
217 ///
218 /// 1. Fill figure background with the theme's `figure_background` color.
219 /// 2. Draw suptitle if one has been set.
220 /// 3. Compute the subplot grid layout, producing one [`Rect`] per axes.
221 /// 4. For each axes, delegate to [`Axes::render`] with its assigned
222 /// rectangle and the figure theme.
223 pub fn render(&self, renderer: &mut impl Renderer) {
224 let (w, h) = renderer.size();
225 let fw = w as f64;
226 let fh = h as f64;
227 let theme = &self.theme;
228
229 // ----- 1. Fill figure background -----------------------------------
230 let bg_path = Path::rect(Rect::new(0.0, 0.0, fw, fh));
231 renderer.fill_path(
232 &bg_path,
233 &Paint::new(theme.figure_background),
234 Affine::IDENTITY,
235 );
236
237 // ----- 2. Draw suptitle if set -------------------------------------
238 let _top_offset = if let Some(ref title) = self.suptitle {
239 let style = TextStyle {
240 size: theme.title_size + 2.0, // suptitle slightly larger than axes title
241 color: theme.text_color,
242 weight: theme.title_weight,
243 family: theme.font_family.clone(),
244 halign: HAlign::Center,
245 valign: VAlign::Top,
246 };
247
248 let text_pos = Point::new(fw / 2.0, DEFAULT_MARGIN * 0.5);
249 renderer.draw_text(title, text_pos, &style, Affine::IDENTITY);
250
251 SUPTITLE_RESERVED_HEIGHT
252 } else {
253 0.0
254 };
255
256 // ----- 3. Compute subplot layout -----------------------------------
257 let grid = self.subplot_grid.unwrap_or((1, 1));
258 let rects = layout::compute_subplot_rects(
259 fw,
260 fh,
261 grid.0,
262 grid.1,
263 DEFAULT_MARGIN,
264 DEFAULT_GAP,
265 );
266
267 // ----- 4. Render each axes -----------------------------------------
268 for (i, axes) in self.axes.iter().enumerate() {
269 if let Some(rect) = rects.get(i) {
270 axes.render(renderer, *rect, theme);
271 }
272 }
273 }
274
275 /// Renders the figure using the given renderer and returns the encoded
276 /// output bytes (PNG, SVG, PDF, etc., depending on the renderer).
277 ///
278 /// This convenience method calls [`render`](Figure::render) and then
279 /// [`Renderer::finalize`] to produce the final byte output.
280 pub fn render_to<R: Renderer>(&self, mut renderer: R) -> Vec<u8> {
281 self.render(&mut renderer);
282 renderer.finalize()
283 }
284
285 /// Saves the figure to a file using the provided renderer.
286 ///
287 /// The renderer determines the output format. The encoded bytes are
288 /// written to `path` atomically via [`std::fs::write`].
289 pub fn save_with<R: Renderer>(&self, renderer: R, path: impl AsRef<std::path::Path>) -> Result<()> {
290 let bytes = self.render_to(renderer);
291 std::fs::write(path, bytes)?;
292 Ok(())
293 }
294}
295
296impl Default for Figure {
297 fn default() -> Self {
298 Self::new()
299 }
300}
301
302// ---------------------------------------------------------------------------
303// Tests
304// ---------------------------------------------------------------------------
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::primitives::Color;
310
311 #[test]
312 fn new_figure_has_default_dimensions() {
313 let fig = Figure::new();
314 assert_eq!(fig.width(), DEFAULT_WIDTH);
315 assert_eq!(fig.height(), DEFAULT_HEIGHT);
316 }
317
318 #[test]
319 fn with_size_sets_dimensions() {
320 let fig = Figure::with_size(1024, 768);
321 assert_eq!(fig.width(), 1024);
322 assert_eq!(fig.height(), 768);
323 }
324
325 #[test]
326 fn default_figure_has_no_axes() {
327 let fig = Figure::new();
328 assert_eq!(fig.num_axes(), 0);
329 }
330
331 #[test]
332 fn add_subplot_creates_axes() {
333 let mut fig = Figure::new();
334 let _ax = fig.add_subplot(1, 1, 1);
335 assert_eq!(fig.num_axes(), 1);
336 }
337
338 #[test]
339 fn add_subplot_returns_same_axes_on_repeat() {
340 let mut fig = Figure::new();
341 fig.add_subplot(2, 2, 1);
342 fig.add_subplot(2, 2, 1); // same index, should not duplicate
343 assert_eq!(fig.num_axes(), 1);
344 }
345
346 #[test]
347 fn add_subplot_pads_for_skipped_indices() {
348 let mut fig = Figure::new();
349 fig.add_subplot(2, 2, 3); // skip indices 1 and 2
350 assert_eq!(fig.num_axes(), 3); // indices 0, 1, 2 all exist
351 }
352
353 #[test]
354 #[should_panic(expected = "nrows must be at least 1")]
355 fn add_subplot_panics_on_zero_rows() {
356 let mut fig = Figure::new();
357 fig.add_subplot(0, 1, 1);
358 }
359
360 #[test]
361 #[should_panic(expected = "ncols must be at least 1")]
362 fn add_subplot_panics_on_zero_cols() {
363 let mut fig = Figure::new();
364 fig.add_subplot(1, 0, 1);
365 }
366
367 #[test]
368 #[should_panic(expected = "subplot index is 1-based")]
369 fn add_subplot_panics_on_zero_index() {
370 let mut fig = Figure::new();
371 fig.add_subplot(1, 1, 0);
372 }
373
374 #[test]
375 #[should_panic(expected = "subplot index 5 exceeds grid size")]
376 fn add_subplot_panics_on_index_out_of_range() {
377 let mut fig = Figure::new();
378 fig.add_subplot(2, 2, 5);
379 }
380
381 #[test]
382 fn suptitle_sets_title() {
383 let mut fig = Figure::new();
384 fig.suptitle("My Figure");
385 assert_eq!(fig.suptitle, Some("My Figure".to_string()));
386 }
387
388 #[test]
389 fn suptitle_returns_self_for_chaining() {
390 let mut fig = Figure::new();
391 fig.suptitle("Title 1").suptitle("Title 2");
392 assert_eq!(fig.suptitle, Some("Title 2".to_string()));
393 }
394
395 #[test]
396 fn set_theme_updates_theme() {
397 let mut fig = Figure::new();
398 let dark = Theme::dark();
399 fig.set_theme(dark);
400 assert_eq!(fig.theme().figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
401 }
402
403 #[test]
404 fn theme_returns_reference() {
405 let fig = Figure::new();
406 assert_eq!(fig.theme().figure_background, Color::WHITE);
407 }
408
409 #[test]
410 fn axes_mut_returns_none_for_out_of_bounds() {
411 let mut fig = Figure::new();
412 assert!(fig.axes_mut(0).is_none());
413 }
414
415 #[test]
416 fn axes_mut_returns_some_for_valid_index() {
417 let mut fig = Figure::new();
418 fig.add_subplot(1, 1, 1);
419 assert!(fig.axes_mut(0).is_some());
420 }
421
422 #[test]
423 fn axes_returns_shared_reference() {
424 let mut fig = Figure::new();
425 fig.add_subplot(1, 1, 1);
426 assert!(fig.axes(0).is_some());
427 assert!(fig.axes(1).is_none());
428 }
429
430 #[test]
431 fn default_impl_matches_new() {
432 let from_new = Figure::new();
433 let from_default = Figure::default();
434 assert_eq!(from_new.width(), from_default.width());
435 assert_eq!(from_new.height(), from_default.height());
436 assert_eq!(from_new.num_axes(), from_default.num_axes());
437 }
438
439 #[test]
440 fn multiple_subplots_in_grid() {
441 let mut fig = Figure::new();
442 fig.add_subplot(2, 3, 1);
443 fig.add_subplot(2, 3, 4);
444 fig.add_subplot(2, 3, 6);
445 assert_eq!(fig.num_axes(), 6); // padded to fill up to index 6
446 assert_eq!(fig.subplot_grid, Some((2, 3)));
447 }
448}