1use crate::axes::{Axes, TwinSide};
9use crate::error::Result;
10use crate::layout;
11use crate::legend;
12use crate::primitives::{Affine, HAlign, Paint, Path, Point, Rect, TextStyle, VAlign};
13use crate::renderer::Renderer;
14use crate::theme::Theme;
15
16const DEFAULT_WIDTH: u32 = 800;
22
23const DEFAULT_HEIGHT: u32 = 600;
25
26const SUPTITLE_RESERVED_HEIGHT: f64 = 30.0;
28
29const DEFAULT_MARGIN: f64 = 20.0;
31
32const DEFAULT_GAP: f64 = 15.0;
34
35#[derive(Debug)]
66pub struct Figure {
67 axes: Vec<Axes>,
69 width: u32,
71 height: u32,
73 suptitle: Option<String>,
75 theme: Theme,
77 subplot_grid: Option<(usize, usize)>,
79 twin_map: Vec<Option<usize>>,
86}
87
88impl Figure {
89 pub fn new() -> Self {
91 Self {
92 axes: Vec::new(),
93 width: DEFAULT_WIDTH,
94 height: DEFAULT_HEIGHT,
95 suptitle: None,
96 theme: Theme::default(),
97 subplot_grid: None,
98 twin_map: Vec::new(),
99 }
100 }
101
102 pub fn with_size(width: u32, height: u32) -> Self {
104 Self {
105 axes: Vec::new(),
106 width,
107 height,
108 suptitle: None,
109 theme: Theme::default(),
110 subplot_grid: None,
111 twin_map: Vec::new(),
112 }
113 }
114
115 pub fn width(&self) -> u32 {
117 self.width
118 }
119
120 pub fn height(&self) -> u32 {
122 self.height
123 }
124
125 pub fn add_subplot(&mut self, nrows: usize, ncols: usize, index: usize) -> &mut Axes {
148 assert!(nrows > 0, "nrows must be at least 1");
149 assert!(ncols > 0, "ncols must be at least 1");
150 assert!(index >= 1, "subplot index is 1-based; got 0");
151 assert!(
152 index <= nrows * ncols,
153 "subplot index {index} exceeds grid size {nrows}x{ncols} = {}",
154 nrows * ncols
155 );
156
157 self.subplot_grid = Some((nrows, ncols));
162
163 let zero_index = index - 1;
165
166 while self.axes.len() <= zero_index {
170 self.axes.push(Axes::new());
171 }
172
173 &mut self.axes[zero_index]
174 }
175
176 pub fn suptitle(&mut self, title: &str) -> &mut Self {
181 self.suptitle = Some(title.to_string());
182 self
183 }
184
185 pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
192 self.theme = theme;
193 self
194 }
195
196 pub fn theme(&self) -> &Theme {
198 &self.theme
199 }
200
201 pub fn axes_mut(&mut self, index: usize) -> Option<&mut Axes> {
205 self.axes.get_mut(index)
206 }
207
208 pub fn axes(&self, index: usize) -> Option<&Axes> {
212 self.axes.get(index)
213 }
214
215 pub fn num_axes(&self) -> usize {
217 self.axes.len()
218 }
219
220 pub fn subplots(nrows: usize, ncols: usize) -> Self {
229 assert!(nrows > 0 && ncols > 0, "subplots: nrows and ncols must be > 0");
230 let mut fig = Self::new();
231 for i in 1..=(nrows * ncols) {
232 fig.add_subplot(nrows, ncols, i);
233 }
234 fig
235 }
236
237 pub fn subplots_with_size(nrows: usize, ncols: usize, width: u32, height: u32) -> Self {
243 assert!(nrows > 0 && ncols > 0, "subplots: nrows and ncols must be > 0");
244 let mut fig = Self::with_size(width, height);
245 for i in 1..=(nrows * ncols) {
246 fig.add_subplot(nrows, ncols, i);
247 }
248 fig
249 }
250
251 pub fn axes_grid(&mut self, row: usize, col: usize, ncols: usize) -> Option<&mut Axes> {
255 self.axes_mut(row * ncols + col)
256 }
257
258 pub fn twinx(&mut self, parent_index: usize) -> &mut Axes {
270 self.add_twin(parent_index, TwinSide::Right)
271 }
272
273 pub fn twiny(&mut self, parent_index: usize) -> &mut Axes {
281 self.add_twin(parent_index, TwinSide::Top)
282 }
283
284 fn add_twin(&mut self, parent_index: usize, side: TwinSide) -> &mut Axes {
286 assert!(
287 parent_index < self.axes.len(),
288 "twinx/twiny: parent_index {parent_index} is out of bounds (have {} axes)",
289 self.axes.len()
290 );
291
292 while self.twin_map.len() <= parent_index {
293 self.twin_map.push(None);
294 }
295 assert!(
296 self.twin_map[parent_index].is_none(),
297 "axes at index {parent_index} already has a twin"
298 );
299
300 let parent_color_index = self.axes[parent_index].color_index;
301 let twin = Axes::new_twin(side, parent_color_index);
302 let twin_index = self.axes.len();
303 self.axes.push(twin);
304 self.twin_map[parent_index] = Some(twin_index);
305
306 &mut self.axes[twin_index]
307 }
308
309 pub fn twin_of(&self, parent_index: usize) -> Option<usize> {
311 self.twin_map.get(parent_index).copied().flatten()
312 }
313
314 pub fn render(&self, renderer: &mut impl Renderer) {
328 let (w, h) = renderer.size();
329 let fw = w as f64;
330 let fh = h as f64;
331 let theme = &self.theme;
332
333 let bg_path = Path::rect(Rect::new(0.0, 0.0, fw, fh));
335 renderer.fill_path(
336 &bg_path,
337 &Paint::new(theme.figure_background),
338 Affine::IDENTITY,
339 );
340
341 let top_offset = if let Some(ref title) = self.suptitle {
343 let style = TextStyle {
344 size: theme.title_size + 2.0, color: theme.text_color,
346 weight: theme.title_weight,
347 family: theme.font_family.clone(),
348 halign: HAlign::Center,
349 valign: VAlign::Top,
350 };
351
352 let text_pos = Point::new(fw / 2.0, DEFAULT_MARGIN * 0.5);
353 renderer.draw_text(title, text_pos, &style, Affine::IDENTITY);
354
355 SUPTITLE_RESERVED_HEIGHT
356 } else {
357 0.0
358 };
359
360 let grid = self.subplot_grid.unwrap_or((1, 1));
364 let rects = layout::compute_subplot_rects(
365 fw,
366 fh - top_offset,
367 grid.0,
368 grid.1,
369 DEFAULT_MARGIN,
370 DEFAULT_GAP,
371 )
372 .into_iter()
373 .map(|mut r| {
374 r.y += top_offset;
375 r
376 })
377 .collect::<Vec<_>>();
378
379 let twin_indices: std::collections::HashSet<usize> = self
382 .twin_map
383 .iter()
384 .filter_map(|opt| *opt)
385 .collect();
386
387 for (i, axes) in self.axes.iter().enumerate() {
388 if twin_indices.contains(&i) {
390 continue;
391 }
392
393 if let Some(rect) = rects.get(i) {
394 let has_twin = self.twin_map.get(i).copied().flatten();
395
396 if let Some(twin_idx) = has_twin {
397 axes.render_primary(renderer, *rect, theme, true);
400
401 if let Some(twin_axes) = self.axes.get(twin_idx) {
402 let plot_area = axes.compute_plot_area(rect);
403 twin_axes.render_twin(renderer, plot_area, *rect, theme);
404
405 if axes.show_legend || twin_axes.show_legend {
408 let mut entries = axes.collect_legend_entries();
409 entries.extend(twin_axes.collect_legend_entries());
410 let loc = if axes.show_legend {
411 axes.legend_loc
412 } else {
413 twin_axes.legend_loc
414 };
415 legend::draw_legend(renderer, &entries, &plot_area, loc, theme);
416 }
417 }
418 } else {
419 axes.render(renderer, *rect, theme);
421 }
422 }
423 }
424 }
425
426 pub fn render_to<R: Renderer>(&self, mut renderer: R) -> Vec<u8> {
432 self.render(&mut renderer);
433 renderer.finalize()
434 }
435
436 pub fn save_with<R: Renderer>(&self, renderer: R, path: impl AsRef<std::path::Path>) -> Result<()> {
441 let bytes = self.render_to(renderer);
442 std::fs::write(path, bytes)?;
443 Ok(())
444 }
445}
446
447impl Default for Figure {
448 fn default() -> Self {
449 Self::new()
450 }
451}
452
453#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::primitives::Color;
461
462 #[test]
463 fn new_figure_has_default_dimensions() {
464 let fig = Figure::new();
465 assert_eq!(fig.width(), DEFAULT_WIDTH);
466 assert_eq!(fig.height(), DEFAULT_HEIGHT);
467 }
468
469 #[test]
470 fn with_size_sets_dimensions() {
471 let fig = Figure::with_size(1024, 768);
472 assert_eq!(fig.width(), 1024);
473 assert_eq!(fig.height(), 768);
474 }
475
476 #[test]
477 fn default_figure_has_no_axes() {
478 let fig = Figure::new();
479 assert_eq!(fig.num_axes(), 0);
480 }
481
482 #[test]
483 fn add_subplot_creates_axes() {
484 let mut fig = Figure::new();
485 let _ax = fig.add_subplot(1, 1, 1);
486 assert_eq!(fig.num_axes(), 1);
487 }
488
489 #[test]
490 fn add_subplot_returns_same_axes_on_repeat() {
491 let mut fig = Figure::new();
492 fig.add_subplot(2, 2, 1);
493 fig.add_subplot(2, 2, 1); assert_eq!(fig.num_axes(), 1);
495 }
496
497 #[test]
498 fn add_subplot_pads_for_skipped_indices() {
499 let mut fig = Figure::new();
500 fig.add_subplot(2, 2, 3); assert_eq!(fig.num_axes(), 3); }
503
504 #[test]
505 #[should_panic(expected = "nrows must be at least 1")]
506 fn add_subplot_panics_on_zero_rows() {
507 let mut fig = Figure::new();
508 fig.add_subplot(0, 1, 1);
509 }
510
511 #[test]
512 #[should_panic(expected = "ncols must be at least 1")]
513 fn add_subplot_panics_on_zero_cols() {
514 let mut fig = Figure::new();
515 fig.add_subplot(1, 0, 1);
516 }
517
518 #[test]
519 #[should_panic(expected = "subplot index is 1-based")]
520 fn add_subplot_panics_on_zero_index() {
521 let mut fig = Figure::new();
522 fig.add_subplot(1, 1, 0);
523 }
524
525 #[test]
526 #[should_panic(expected = "subplot index 5 exceeds grid size")]
527 fn add_subplot_panics_on_index_out_of_range() {
528 let mut fig = Figure::new();
529 fig.add_subplot(2, 2, 5);
530 }
531
532 #[test]
533 fn suptitle_sets_title() {
534 let mut fig = Figure::new();
535 fig.suptitle("My Figure");
536 assert_eq!(fig.suptitle, Some("My Figure".to_string()));
537 }
538
539 #[test]
540 fn suptitle_returns_self_for_chaining() {
541 let mut fig = Figure::new();
542 fig.suptitle("Title 1").suptitle("Title 2");
543 assert_eq!(fig.suptitle, Some("Title 2".to_string()));
544 }
545
546 #[test]
547 fn set_theme_updates_theme() {
548 let mut fig = Figure::new();
549 let dark = Theme::dark();
550 fig.set_theme(dark);
551 assert_eq!(fig.theme().figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
552 }
553
554 #[test]
555 fn theme_returns_reference() {
556 let fig = Figure::new();
557 assert_eq!(fig.theme().figure_background, Color::WHITE);
558 }
559
560 #[test]
561 fn axes_mut_returns_none_for_out_of_bounds() {
562 let mut fig = Figure::new();
563 assert!(fig.axes_mut(0).is_none());
564 }
565
566 #[test]
567 fn axes_mut_returns_some_for_valid_index() {
568 let mut fig = Figure::new();
569 fig.add_subplot(1, 1, 1);
570 assert!(fig.axes_mut(0).is_some());
571 }
572
573 #[test]
574 fn axes_returns_shared_reference() {
575 let mut fig = Figure::new();
576 fig.add_subplot(1, 1, 1);
577 assert!(fig.axes(0).is_some());
578 assert!(fig.axes(1).is_none());
579 }
580
581 #[test]
582 fn default_impl_matches_new() {
583 let from_new = Figure::new();
584 let from_default = Figure::default();
585 assert_eq!(from_new.width(), from_default.width());
586 assert_eq!(from_new.height(), from_default.height());
587 assert_eq!(from_new.num_axes(), from_default.num_axes());
588 }
589
590 #[test]
591 fn multiple_subplots_in_grid() {
592 let mut fig = Figure::new();
593 fig.add_subplot(2, 3, 1);
594 fig.add_subplot(2, 3, 4);
595 fig.add_subplot(2, 3, 6);
596 assert_eq!(fig.num_axes(), 6); assert_eq!(fig.subplot_grid, Some((2, 3)));
598 }
599
600 #[test]
605 fn twinx_creates_new_axes() {
606 let mut fig = Figure::new();
607 fig.add_subplot(1, 1, 1);
608 let ax2 = fig.twinx(0);
609 assert!(ax2.is_twin());
610 assert_eq!(ax2.twin_side(), Some(TwinSide::Right));
611 assert_eq!(fig.num_axes(), 2);
612 }
613
614 #[test]
615 fn twiny_creates_new_axes() {
616 let mut fig = Figure::new();
617 fig.add_subplot(1, 1, 1);
618 let ax2 = fig.twiny(0);
619 assert!(ax2.is_twin());
620 assert_eq!(ax2.twin_side(), Some(TwinSide::Top));
621 assert_eq!(fig.num_axes(), 2);
622 }
623
624 #[test]
625 fn twinx_links_to_parent() {
626 let mut fig = Figure::new();
627 fig.add_subplot(1, 1, 1);
628 fig.twinx(0);
629 assert_eq!(fig.twin_of(0), Some(1));
630 }
631
632 #[test]
633 fn twin_has_independent_ylimits() {
634 let mut fig = Figure::new();
635 fig.add_subplot(1, 1, 1);
636 fig.axes_mut(0).unwrap().set_ylim(0.0, 100.0);
637 fig.twinx(0);
638 fig.axes_mut(1).unwrap().set_ylim(900.0, 1100.0);
639 assert_eq!(fig.axes(0).unwrap().ylim, Some((0.0, 100.0)));
640 assert_eq!(fig.axes(1).unwrap().ylim, Some((900.0, 1100.0)));
641 }
642
643 #[test]
644 fn twinx_inherits_color_cycle() {
645 let mut fig = Figure::new();
646 let ax = fig.add_subplot(1, 1, 1);
647 ax.plot(vec![1.0, 2.0], vec![3.0, 4.0]).unwrap();
648 let ax2 = fig.twinx(0);
649 ax2.plot(vec![1.0, 2.0], vec![5.0, 6.0]).unwrap();
650 let twin = fig.axes(1).unwrap();
651 match &twin.artists[0] {
652 crate::artist::Artist::Line(a) => {
653 assert_eq!(a.color, Color::TABLEAU_10[1]);
654 }
655 _ => panic!("expected Line artist"),
656 }
657 }
658
659 #[test]
660 fn primary_axes_is_not_twin() {
661 let mut fig = Figure::new();
662 fig.add_subplot(1, 1, 1);
663 assert!(!fig.axes(0).unwrap().is_twin());
664 assert_eq!(fig.axes(0).unwrap().twin_side(), None);
665 }
666
667 #[test]
668 fn twin_of_returns_none_when_no_twin() {
669 let mut fig = Figure::new();
670 fig.add_subplot(1, 1, 1);
671 assert_eq!(fig.twin_of(0), None);
672 }
673
674 #[test]
675 #[should_panic(expected = "parent_index 5 is out of bounds")]
676 fn twinx_panics_on_out_of_bounds() {
677 let mut fig = Figure::new();
678 fig.add_subplot(1, 1, 1);
679 fig.twinx(5);
680 }
681
682 #[test]
683 #[should_panic(expected = "already has a twin")]
684 fn twinx_panics_on_duplicate_twin() {
685 let mut fig = Figure::new();
686 fig.add_subplot(1, 1, 1);
687 fig.twinx(0);
688 fig.twinx(0);
689 }
690
691 #[test]
692 fn multiple_subplots_with_different_twins() {
693 let mut fig = Figure::new();
694 fig.add_subplot(1, 2, 1);
695 fig.add_subplot(1, 2, 2);
696 fig.twinx(0);
697 fig.twiny(1);
698 assert_eq!(fig.twin_of(0), Some(2));
699 assert_eq!(fig.twin_of(1), Some(3));
700 assert_eq!(fig.num_axes(), 4);
701 }
702}