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(crate::text::format_markup(title));
182 self
183 }
184
185 pub fn tight_layout(&mut self) -> &mut Self {
194 self
197 }
198
199 pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
206 self.theme = theme;
207 self
208 }
209
210 pub fn theme(&self) -> &Theme {
212 &self.theme
213 }
214
215 pub fn axes_mut(&mut self, index: usize) -> Option<&mut Axes> {
219 self.axes.get_mut(index)
220 }
221
222 pub fn axes(&self, index: usize) -> Option<&Axes> {
226 self.axes.get(index)
227 }
228
229 pub fn num_axes(&self) -> usize {
231 self.axes.len()
232 }
233
234 pub fn subplots(nrows: usize, ncols: usize) -> Self {
243 assert!(
244 nrows > 0 && ncols > 0,
245 "subplots: nrows and ncols must be > 0"
246 );
247 let mut fig = Self::new();
248 for i in 1..=(nrows * ncols) {
249 fig.add_subplot(nrows, ncols, i);
250 }
251 fig
252 }
253
254 pub fn subplots_with_size(nrows: usize, ncols: usize, width: u32, height: u32) -> Self {
260 assert!(
261 nrows > 0 && ncols > 0,
262 "subplots: nrows and ncols must be > 0"
263 );
264 let mut fig = Self::with_size(width, height);
265 for i in 1..=(nrows * ncols) {
266 fig.add_subplot(nrows, ncols, i);
267 }
268 fig
269 }
270
271 pub fn axes_grid(&mut self, row: usize, col: usize, ncols: usize) -> Option<&mut Axes> {
275 self.axes_mut(row * ncols + col)
276 }
277
278 pub fn twinx(&mut self, parent_index: usize) -> &mut Axes {
290 self.add_twin(parent_index, TwinSide::Right)
291 }
292
293 pub fn twiny(&mut self, parent_index: usize) -> &mut Axes {
301 self.add_twin(parent_index, TwinSide::Top)
302 }
303
304 fn add_twin(&mut self, parent_index: usize, side: TwinSide) -> &mut Axes {
306 assert!(
307 parent_index < self.axes.len(),
308 "twinx/twiny: parent_index {parent_index} is out of bounds (have {} axes)",
309 self.axes.len()
310 );
311
312 while self.twin_map.len() <= parent_index {
313 self.twin_map.push(None);
314 }
315 assert!(
316 self.twin_map[parent_index].is_none(),
317 "axes at index {parent_index} already has a twin"
318 );
319
320 let parent_color_index = self.axes[parent_index].color_index;
321 let twin = Axes::new_twin(side, parent_color_index);
322 let twin_index = self.axes.len();
323 self.axes.push(twin);
324 self.twin_map[parent_index] = Some(twin_index);
325
326 &mut self.axes[twin_index]
327 }
328
329 pub fn twin_of(&self, parent_index: usize) -> Option<usize> {
331 self.twin_map.get(parent_index).copied().flatten()
332 }
333
334 pub fn render(&self, renderer: &mut impl Renderer) {
348 let (w, h) = renderer.size();
349 let fw = w as f64;
350 let fh = h as f64;
351 let theme = &self.theme;
352
353 let bg_path = Path::rect(Rect::new(0.0, 0.0, fw, fh));
355 renderer.fill_path(
356 &bg_path,
357 &Paint::new(theme.figure_background),
358 Affine::IDENTITY,
359 );
360
361 let top_offset = if let Some(ref title) = self.suptitle {
363 let style = TextStyle {
364 size: theme.title_size + 2.0, color: theme.text_color,
366 weight: theme.title_weight,
367 family: theme.font_family.clone(),
368 halign: HAlign::Center,
369 valign: VAlign::Top,
370 };
371
372 let text_pos = Point::new(fw / 2.0, DEFAULT_MARGIN * 0.5);
373 renderer.draw_text(title, text_pos, &style, Affine::IDENTITY);
374
375 SUPTITLE_RESERVED_HEIGHT
376 } else {
377 0.0
378 };
379
380 let grid = self.subplot_grid.unwrap_or((1, 1));
384 let rects = layout::compute_subplot_rects(
385 fw,
386 fh - top_offset,
387 grid.0,
388 grid.1,
389 DEFAULT_MARGIN,
390 DEFAULT_GAP,
391 )
392 .into_iter()
393 .map(|mut r| {
394 r.y += top_offset;
395 r
396 })
397 .collect::<Vec<_>>();
398
399 let twin_indices: std::collections::HashSet<usize> =
402 self.twin_map.iter().filter_map(|opt| *opt).collect();
403
404 for (i, axes) in self.axes.iter().enumerate() {
405 if twin_indices.contains(&i) {
407 continue;
408 }
409
410 if let Some(rect) = rects.get(i) {
411 let has_twin = self.twin_map.get(i).copied().flatten();
412
413 if let Some(twin_idx) = has_twin {
414 axes.render_primary(renderer, *rect, theme, true);
417
418 if let Some(twin_axes) = self.axes.get(twin_idx) {
419 let plot_area = axes.compute_plot_area(rect);
420 twin_axes.render_twin(renderer, plot_area, *rect, theme);
421
422 if axes.show_legend || twin_axes.show_legend {
425 let mut entries = axes.collect_legend_entries();
426 entries.extend(twin_axes.collect_legend_entries());
427 let loc = if axes.show_legend {
428 axes.legend_loc
429 } else {
430 twin_axes.legend_loc
431 };
432 legend::draw_legend(renderer, &entries, &plot_area, loc, theme);
433 }
434 }
435 } else {
436 axes.render(renderer, *rect, theme);
438 }
439 }
440 }
441 }
442
443 pub fn render_to<R: Renderer>(&self, mut renderer: R) -> Vec<u8> {
449 self.render(&mut renderer);
450 renderer.finalize()
451 }
452
453 pub fn save_with<R: Renderer>(
458 &self,
459 renderer: R,
460 path: impl AsRef<std::path::Path>,
461 ) -> Result<()> {
462 let bytes = self.render_to(renderer);
463 std::fs::write(path, bytes)?;
464 Ok(())
465 }
466}
467
468impl Default for Figure {
469 fn default() -> Self {
470 Self::new()
471 }
472}
473
474#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::primitives::Color;
482
483 #[test]
484 fn new_figure_has_default_dimensions() {
485 let fig = Figure::new();
486 assert_eq!(fig.width(), DEFAULT_WIDTH);
487 assert_eq!(fig.height(), DEFAULT_HEIGHT);
488 }
489
490 #[test]
491 fn with_size_sets_dimensions() {
492 let fig = Figure::with_size(1024, 768);
493 assert_eq!(fig.width(), 1024);
494 assert_eq!(fig.height(), 768);
495 }
496
497 #[test]
498 fn default_figure_has_no_axes() {
499 let fig = Figure::new();
500 assert_eq!(fig.num_axes(), 0);
501 }
502
503 #[test]
504 fn add_subplot_creates_axes() {
505 let mut fig = Figure::new();
506 let _ax = fig.add_subplot(1, 1, 1);
507 assert_eq!(fig.num_axes(), 1);
508 }
509
510 #[test]
511 fn add_subplot_returns_same_axes_on_repeat() {
512 let mut fig = Figure::new();
513 fig.add_subplot(2, 2, 1);
514 fig.add_subplot(2, 2, 1); assert_eq!(fig.num_axes(), 1);
516 }
517
518 #[test]
519 fn add_subplot_pads_for_skipped_indices() {
520 let mut fig = Figure::new();
521 fig.add_subplot(2, 2, 3); assert_eq!(fig.num_axes(), 3); }
524
525 #[test]
526 #[should_panic(expected = "nrows must be at least 1")]
527 fn add_subplot_panics_on_zero_rows() {
528 let mut fig = Figure::new();
529 fig.add_subplot(0, 1, 1);
530 }
531
532 #[test]
533 #[should_panic(expected = "ncols must be at least 1")]
534 fn add_subplot_panics_on_zero_cols() {
535 let mut fig = Figure::new();
536 fig.add_subplot(1, 0, 1);
537 }
538
539 #[test]
540 #[should_panic(expected = "subplot index is 1-based")]
541 fn add_subplot_panics_on_zero_index() {
542 let mut fig = Figure::new();
543 fig.add_subplot(1, 1, 0);
544 }
545
546 #[test]
547 #[should_panic(expected = "subplot index 5 exceeds grid size")]
548 fn add_subplot_panics_on_index_out_of_range() {
549 let mut fig = Figure::new();
550 fig.add_subplot(2, 2, 5);
551 }
552
553 #[test]
554 fn suptitle_sets_title() {
555 let mut fig = Figure::new();
556 fig.suptitle("My Figure");
557 assert_eq!(fig.suptitle, Some("My Figure".to_string()));
558 }
559
560 #[test]
561 fn suptitle_returns_self_for_chaining() {
562 let mut fig = Figure::new();
563 fig.suptitle("Title 1").suptitle("Title 2");
564 assert_eq!(fig.suptitle, Some("Title 2".to_string()));
565 }
566
567 #[test]
568 fn set_theme_updates_theme() {
569 let mut fig = Figure::new();
570 let dark = Theme::dark();
571 fig.set_theme(dark);
572 assert_eq!(fig.theme().figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
573 }
574
575 #[test]
576 fn theme_returns_reference() {
577 let fig = Figure::new();
578 assert_eq!(fig.theme().figure_background, Color::WHITE);
579 }
580
581 #[test]
582 fn axes_mut_returns_none_for_out_of_bounds() {
583 let mut fig = Figure::new();
584 assert!(fig.axes_mut(0).is_none());
585 }
586
587 #[test]
588 fn axes_mut_returns_some_for_valid_index() {
589 let mut fig = Figure::new();
590 fig.add_subplot(1, 1, 1);
591 assert!(fig.axes_mut(0).is_some());
592 }
593
594 #[test]
595 fn axes_returns_shared_reference() {
596 let mut fig = Figure::new();
597 fig.add_subplot(1, 1, 1);
598 assert!(fig.axes(0).is_some());
599 assert!(fig.axes(1).is_none());
600 }
601
602 #[test]
603 fn default_impl_matches_new() {
604 let from_new = Figure::new();
605 let from_default = Figure::default();
606 assert_eq!(from_new.width(), from_default.width());
607 assert_eq!(from_new.height(), from_default.height());
608 assert_eq!(from_new.num_axes(), from_default.num_axes());
609 }
610
611 #[test]
612 fn multiple_subplots_in_grid() {
613 let mut fig = Figure::new();
614 fig.add_subplot(2, 3, 1);
615 fig.add_subplot(2, 3, 4);
616 fig.add_subplot(2, 3, 6);
617 assert_eq!(fig.num_axes(), 6); assert_eq!(fig.subplot_grid, Some((2, 3)));
619 }
620
621 #[test]
626 fn twinx_creates_new_axes() {
627 let mut fig = Figure::new();
628 fig.add_subplot(1, 1, 1);
629 let ax2 = fig.twinx(0);
630 assert!(ax2.is_twin());
631 assert_eq!(ax2.twin_side(), Some(TwinSide::Right));
632 assert_eq!(fig.num_axes(), 2);
633 }
634
635 #[test]
636 fn twiny_creates_new_axes() {
637 let mut fig = Figure::new();
638 fig.add_subplot(1, 1, 1);
639 let ax2 = fig.twiny(0);
640 assert!(ax2.is_twin());
641 assert_eq!(ax2.twin_side(), Some(TwinSide::Top));
642 assert_eq!(fig.num_axes(), 2);
643 }
644
645 #[test]
646 fn twinx_links_to_parent() {
647 let mut fig = Figure::new();
648 fig.add_subplot(1, 1, 1);
649 fig.twinx(0);
650 assert_eq!(fig.twin_of(0), Some(1));
651 }
652
653 #[test]
654 fn twin_has_independent_ylimits() {
655 let mut fig = Figure::new();
656 fig.add_subplot(1, 1, 1);
657 fig.axes_mut(0).unwrap().set_ylim(0.0, 100.0);
658 fig.twinx(0);
659 fig.axes_mut(1).unwrap().set_ylim(900.0, 1100.0);
660 assert_eq!(fig.axes(0).unwrap().ylim, Some((0.0, 100.0)));
661 assert_eq!(fig.axes(1).unwrap().ylim, Some((900.0, 1100.0)));
662 }
663
664 #[test]
665 fn twinx_inherits_color_cycle() {
666 let mut fig = Figure::new();
667 let ax = fig.add_subplot(1, 1, 1);
668 ax.plot(vec![1.0, 2.0], vec![3.0, 4.0]).unwrap();
669 let ax2 = fig.twinx(0);
670 ax2.plot(vec![1.0, 2.0], vec![5.0, 6.0]).unwrap();
671 let twin = fig.axes(1).unwrap();
672 match &twin.artists[0] {
673 crate::artist::Artist::Line(a) => {
674 assert_eq!(a.color, Color::TABLEAU_10[1]);
675 }
676 _ => panic!("expected Line artist"),
677 }
678 }
679
680 #[test]
681 fn primary_axes_is_not_twin() {
682 let mut fig = Figure::new();
683 fig.add_subplot(1, 1, 1);
684 assert!(!fig.axes(0).unwrap().is_twin());
685 assert_eq!(fig.axes(0).unwrap().twin_side(), None);
686 }
687
688 #[test]
689 fn twin_of_returns_none_when_no_twin() {
690 let mut fig = Figure::new();
691 fig.add_subplot(1, 1, 1);
692 assert_eq!(fig.twin_of(0), None);
693 }
694
695 #[test]
696 #[should_panic(expected = "parent_index 5 is out of bounds")]
697 fn twinx_panics_on_out_of_bounds() {
698 let mut fig = Figure::new();
699 fig.add_subplot(1, 1, 1);
700 fig.twinx(5);
701 }
702
703 #[test]
704 #[should_panic(expected = "already has a twin")]
705 fn twinx_panics_on_duplicate_twin() {
706 let mut fig = Figure::new();
707 fig.add_subplot(1, 1, 1);
708 fig.twinx(0);
709 fig.twinx(0);
710 }
711
712 #[test]
713 fn multiple_subplots_with_different_twins() {
714 let mut fig = Figure::new();
715 fig.add_subplot(1, 2, 1);
716 fig.add_subplot(1, 2, 2);
717 fig.twinx(0);
718 fig.twiny(1);
719 assert_eq!(fig.twin_of(0), Some(2));
720 assert_eq!(fig.twin_of(1), Some(3));
721 assert_eq!(fig.num_axes(), 4);
722 }
723}