use crate::axes::{Axes, TwinSide};
use crate::error::Result;
use crate::layout;
use crate::legend;
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)>,
twin_map: Vec<Option<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,
twin_map: Vec::new(),
}
}
pub fn with_size(width: u32, height: u32) -> Self {
Self {
axes: Vec::new(),
width,
height,
suptitle: None,
theme: Theme::default(),
subplot_grid: None,
twin_map: Vec::new(),
}
}
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 subplots(nrows: usize, ncols: usize) -> Self {
assert!(nrows > 0 && ncols > 0, "subplots: nrows and ncols must be > 0");
let mut fig = Self::new();
for i in 1..=(nrows * ncols) {
fig.add_subplot(nrows, ncols, i);
}
fig
}
pub fn subplots_with_size(nrows: usize, ncols: usize, width: u32, height: u32) -> Self {
assert!(nrows > 0 && ncols > 0, "subplots: nrows and ncols must be > 0");
let mut fig = Self::with_size(width, height);
for i in 1..=(nrows * ncols) {
fig.add_subplot(nrows, ncols, i);
}
fig
}
pub fn axes_grid(&mut self, row: usize, col: usize, ncols: usize) -> Option<&mut Axes> {
self.axes_mut(row * ncols + col)
}
pub fn twinx(&mut self, parent_index: usize) -> &mut Axes {
self.add_twin(parent_index, TwinSide::Right)
}
pub fn twiny(&mut self, parent_index: usize) -> &mut Axes {
self.add_twin(parent_index, TwinSide::Top)
}
fn add_twin(&mut self, parent_index: usize, side: TwinSide) -> &mut Axes {
assert!(
parent_index < self.axes.len(),
"twinx/twiny: parent_index {parent_index} is out of bounds (have {} axes)",
self.axes.len()
);
while self.twin_map.len() <= parent_index {
self.twin_map.push(None);
}
assert!(
self.twin_map[parent_index].is_none(),
"axes at index {parent_index} already has a twin"
);
let parent_color_index = self.axes[parent_index].color_index;
let twin = Axes::new_twin(side, parent_color_index);
let twin_index = self.axes.len();
self.axes.push(twin);
self.twin_map[parent_index] = Some(twin_index);
&mut self.axes[twin_index]
}
pub fn twin_of(&self, parent_index: usize) -> Option<usize> {
self.twin_map.get(parent_index).copied().flatten()
}
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<_>>();
let twin_indices: std::collections::HashSet<usize> = self
.twin_map
.iter()
.filter_map(|opt| *opt)
.collect();
for (i, axes) in self.axes.iter().enumerate() {
if twin_indices.contains(&i) {
continue;
}
if let Some(rect) = rects.get(i) {
let has_twin = self.twin_map.get(i).copied().flatten();
if let Some(twin_idx) = has_twin {
axes.render_primary(renderer, *rect, theme, true);
if let Some(twin_axes) = self.axes.get(twin_idx) {
let plot_area = axes.compute_plot_area(rect);
twin_axes.render_twin(renderer, plot_area, *rect, theme);
if axes.show_legend || twin_axes.show_legend {
let mut entries = axes.collect_legend_entries();
entries.extend(twin_axes.collect_legend_entries());
let loc = if axes.show_legend {
axes.legend_loc
} else {
twin_axes.legend_loc
};
legend::draw_legend(renderer, &entries, &plot_area, loc, theme);
}
}
} else {
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)));
}
#[test]
fn twinx_creates_new_axes() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
let ax2 = fig.twinx(0);
assert!(ax2.is_twin());
assert_eq!(ax2.twin_side(), Some(TwinSide::Right));
assert_eq!(fig.num_axes(), 2);
}
#[test]
fn twiny_creates_new_axes() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
let ax2 = fig.twiny(0);
assert!(ax2.is_twin());
assert_eq!(ax2.twin_side(), Some(TwinSide::Top));
assert_eq!(fig.num_axes(), 2);
}
#[test]
fn twinx_links_to_parent() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
fig.twinx(0);
assert_eq!(fig.twin_of(0), Some(1));
}
#[test]
fn twin_has_independent_ylimits() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
fig.axes_mut(0).unwrap().set_ylim(0.0, 100.0);
fig.twinx(0);
fig.axes_mut(1).unwrap().set_ylim(900.0, 1100.0);
assert_eq!(fig.axes(0).unwrap().ylim, Some((0.0, 100.0)));
assert_eq!(fig.axes(1).unwrap().ylim, Some((900.0, 1100.0)));
}
#[test]
fn twinx_inherits_color_cycle() {
let mut fig = Figure::new();
let ax = fig.add_subplot(1, 1, 1);
ax.plot(vec![1.0, 2.0], vec![3.0, 4.0]).unwrap();
let ax2 = fig.twinx(0);
ax2.plot(vec![1.0, 2.0], vec![5.0, 6.0]).unwrap();
let twin = fig.axes(1).unwrap();
match &twin.artists[0] {
crate::artist::Artist::Line(a) => {
assert_eq!(a.color, Color::TABLEAU_10[1]);
}
_ => panic!("expected Line artist"),
}
}
#[test]
fn primary_axes_is_not_twin() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
assert!(!fig.axes(0).unwrap().is_twin());
assert_eq!(fig.axes(0).unwrap().twin_side(), None);
}
#[test]
fn twin_of_returns_none_when_no_twin() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
assert_eq!(fig.twin_of(0), None);
}
#[test]
#[should_panic(expected = "parent_index 5 is out of bounds")]
fn twinx_panics_on_out_of_bounds() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
fig.twinx(5);
}
#[test]
#[should_panic(expected = "already has a twin")]
fn twinx_panics_on_duplicate_twin() {
let mut fig = Figure::new();
fig.add_subplot(1, 1, 1);
fig.twinx(0);
fig.twinx(0);
}
#[test]
fn multiple_subplots_with_different_twins() {
let mut fig = Figure::new();
fig.add_subplot(1, 2, 1);
fig.add_subplot(1, 2, 2);
fig.twinx(0);
fig.twiny(1);
assert_eq!(fig.twin_of(0), Some(2));
assert_eq!(fig.twin_of(1), Some(3));
assert_eq!(fig.num_axes(), 4);
}
}