use crate::axes::Axes;
use crate::error::Result;
use crate::layout;
use crate::primitives::{Affine, HAlign, Paint, Path, Point, Rect, TextStyle, VAlign};
use crate::renderer::Renderer;
use crate::theme::Theme;
const DEFAULT_WIDTH: u32 = 800;
const DEFAULT_HEIGHT: u32 = 600;
const SUPTITLE_RESERVED_HEIGHT: f64 = 30.0;
const DEFAULT_MARGIN: f64 = 20.0;
const DEFAULT_GAP: f64 = 15.0;
#[derive(Debug)]
pub struct Figure {
axes: Vec<Axes>,
width: u32,
height: u32,
suptitle: Option<String>,
theme: Theme,
subplot_grid: Option<(usize, usize)>,
}
impl Figure {
pub fn new() -> Self {
Self {
axes: Vec::new(),
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
suptitle: None,
theme: Theme::default(),
subplot_grid: None,
}
}
pub fn with_size(width: u32, height: u32) -> Self {
Self {
axes: Vec::new(),
width,
height,
suptitle: None,
theme: Theme::default(),
subplot_grid: None,
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn add_subplot(&mut self, nrows: usize, ncols: usize, index: usize) -> &mut Axes {
assert!(nrows > 0, "nrows must be at least 1");
assert!(ncols > 0, "ncols must be at least 1");
assert!(index >= 1, "subplot index is 1-based; got 0");
assert!(
index <= nrows * ncols,
"subplot index {index} exceeds grid size {nrows}x{ncols} = {}",
nrows * ncols
);
self.subplot_grid = Some((nrows, ncols));
let zero_index = index - 1;
while self.axes.len() <= zero_index {
self.axes.push(Axes::new());
}
&mut self.axes[zero_index]
}
pub fn suptitle(&mut self, title: &str) -> &mut Self {
self.suptitle = Some(title.to_string());
self
}
pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
self.theme = theme;
self
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn axes_mut(&mut self, index: usize) -> Option<&mut Axes> {
self.axes.get_mut(index)
}
pub fn axes(&self, index: usize) -> Option<&Axes> {
self.axes.get(index)
}
pub fn num_axes(&self) -> usize {
self.axes.len()
}
pub fn render(&self, renderer: &mut impl Renderer) {
let (w, h) = renderer.size();
let fw = w as f64;
let fh = h as f64;
let theme = &self.theme;
let bg_path = Path::rect(Rect::new(0.0, 0.0, fw, fh));
renderer.fill_path(
&bg_path,
&Paint::new(theme.figure_background),
Affine::IDENTITY,
);
let top_offset = if let Some(ref title) = self.suptitle {
let style = TextStyle {
size: theme.title_size + 2.0, color: theme.text_color,
weight: theme.title_weight,
family: theme.font_family.clone(),
halign: HAlign::Center,
valign: VAlign::Top,
};
let text_pos = Point::new(fw / 2.0, DEFAULT_MARGIN * 0.5);
renderer.draw_text(title, text_pos, &style, Affine::IDENTITY);
SUPTITLE_RESERVED_HEIGHT
} else {
0.0
};
let grid = self.subplot_grid.unwrap_or((1, 1));
let rects = layout::compute_subplot_rects(
fw,
fh - top_offset,
grid.0,
grid.1,
DEFAULT_MARGIN,
DEFAULT_GAP,
)
.into_iter()
.map(|mut r| {
r.y += top_offset;
r
})
.collect::<Vec<_>>();
for (i, axes) in self.axes.iter().enumerate() {
if let Some(rect) = rects.get(i) {
axes.render(renderer, *rect, theme);
}
}
}
pub fn render_to<R: Renderer>(&self, mut renderer: R) -> Vec<u8> {
self.render(&mut renderer);
renderer.finalize()
}
pub fn save_with<R: Renderer>(&self, renderer: R, path: impl AsRef<std::path::Path>) -> Result<()> {
let bytes = self.render_to(renderer);
std::fs::write(path, bytes)?;
Ok(())
}
}
impl Default for Figure {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::Color;
#[test]
fn new_figure_has_default_dimensions() {
let fig = Figure::new();
assert_eq!(fig.width(), DEFAULT_WIDTH);
assert_eq!(fig.height(), DEFAULT_HEIGHT);
}
#[test]
fn with_size_sets_dimensions() {
let fig = Figure::with_size(1024, 768);
assert_eq!(fig.width(), 1024);
assert_eq!(fig.height(), 768);
}
#[test]
fn default_figure_has_no_axes() {
let fig = Figure::new();
assert_eq!(fig.num_axes(), 0);
}
#[test]
fn add_subplot_creates_axes() {
let mut fig = Figure::new();
let _ax = fig.add_subplot(1, 1, 1);
assert_eq!(fig.num_axes(), 1);
}
#[test]
fn add_subplot_returns_same_axes_on_repeat() {
let mut fig = Figure::new();
fig.add_subplot(2, 2, 1);
fig.add_subplot(2, 2, 1); assert_eq!(fig.num_axes(), 1);
}
#[test]
fn add_subplot_pads_for_skipped_indices() {
let mut fig = Figure::new();
fig.add_subplot(2, 2, 3); assert_eq!(fig.num_axes(), 3); }
#[test]
#[should_panic(expected = "nrows must be at least 1")]
fn add_subplot_panics_on_zero_rows() {
let mut fig = Figure::new();
fig.add_subplot(0, 1, 1);
}
#[test]
#[should_panic(expected = "ncols must be at least 1")]
fn add_subplot_panics_on_zero_cols() {
let mut fig = Figure::new();
fig.add_subplot(1, 0, 1);
}
#[test]
#[should_panic(expected = "subplot index is 1-based")]
fn add_subplot_panics_on_zero_index() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 0);
}
#[test]
#[should_panic(expected = "subplot index 5 exceeds grid size")]
fn add_subplot_panics_on_index_out_of_range() {
let mut fig = Figure::new();
fig.add_subplot(2, 2, 5);
}
#[test]
fn suptitle_sets_title() {
let mut fig = Figure::new();
fig.suptitle("My Figure");
assert_eq!(fig.suptitle, Some("My Figure".to_string()));
}
#[test]
fn suptitle_returns_self_for_chaining() {
let mut fig = Figure::new();
fig.suptitle("Title 1").suptitle("Title 2");
assert_eq!(fig.suptitle, Some("Title 2".to_string()));
}
#[test]
fn set_theme_updates_theme() {
let mut fig = Figure::new();
let dark = Theme::dark();
fig.set_theme(dark);
assert_eq!(fig.theme().figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
}
#[test]
fn theme_returns_reference() {
let fig = Figure::new();
assert_eq!(fig.theme().figure_background, Color::WHITE);
}
#[test]
fn axes_mut_returns_none_for_out_of_bounds() {
let mut fig = Figure::new();
assert!(fig.axes_mut(0).is_none());
}
#[test]
fn axes_mut_returns_some_for_valid_index() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
assert!(fig.axes_mut(0).is_some());
}
#[test]
fn axes_returns_shared_reference() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
assert!(fig.axes(0).is_some());
assert!(fig.axes(1).is_none());
}
#[test]
fn default_impl_matches_new() {
let from_new = Figure::new();
let from_default = Figure::default();
assert_eq!(from_new.width(), from_default.width());
assert_eq!(from_new.height(), from_default.height());
assert_eq!(from_new.num_axes(), from_default.num_axes());
}
#[test]
fn multiple_subplots_in_grid() {
let mut fig = Figure::new();
fig.add_subplot(2, 3, 1);
fig.add_subplot(2, 3, 4);
fig.add_subplot(2, 3, 6);
assert_eq!(fig.num_axes(), 6); assert_eq!(fig.subplot_grid, Some((2, 3)));
}
}