1use crate::primitives::Rect;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Margins {
16 pub top: f64,
18 pub right: f64,
20 pub bottom: f64,
22 pub left: f64,
24}
25
26impl Default for Margins {
27 fn default() -> Self {
28 Self {
29 top: 0.0,
30 right: 0.0,
31 bottom: 0.0,
32 left: 0.0,
33 }
34 }
35}
36
37impl Margins {
38 pub fn uniform(value: f64) -> Self {
40 Self {
41 top: value,
42 right: value,
43 bottom: value,
44 left: value,
45 }
46 }
47
48 pub fn new(top: f64, right: f64, bottom: f64, left: f64) -> Self {
50 Self {
51 top,
52 right,
53 bottom,
54 left,
55 }
56 }
57
58 pub fn horizontal(&self) -> f64 {
60 self.left + self.right
61 }
62
63 pub fn vertical(&self) -> f64 {
65 self.top + self.bottom
66 }
67}
68
69#[derive(Debug, Clone)]
78pub struct LayoutResult {
79 pub plot_area: Rect,
81 pub title_area: Option<Rect>,
83 pub xlabel_area: Option<Rect>,
85 pub ylabel_area: Option<Rect>,
87 pub legend_area: Option<Rect>,
89 pub tick_label_margins: Margins,
91}
92
93#[derive(Debug, Clone)]
102pub struct LayoutConfig {
103 pub figure_width: f64,
105 pub figure_height: f64,
107 pub has_title: bool,
109 pub has_xlabel: bool,
111 pub has_ylabel: bool,
113 pub has_legend: bool,
115 pub title_height: f64,
117 pub xlabel_height: f64,
119 pub ylabel_width: f64,
121 pub tick_label_max_width: f64,
123 pub tick_label_height: f64,
125 pub legend_width: f64,
127 pub padding: f64,
129 pub min_plot_width: f64,
131 pub min_plot_height: f64,
133}
134
135impl LayoutConfig {
136 pub fn new(width: f64, height: f64) -> Self {
141 Self {
142 figure_width: width,
143 figure_height: height,
144 has_title: false,
145 has_xlabel: false,
146 has_ylabel: false,
147 has_legend: false,
148 title_height: 20.0,
149 xlabel_height: 16.0,
150 ylabel_width: 16.0,
151 tick_label_max_width: 40.0,
152 tick_label_height: 12.0,
153 legend_width: 80.0,
154 padding: 10.0,
155 min_plot_width: 60.0,
156 min_plot_height: 40.0,
157 }
158 }
159}
160
161pub fn compute_layout(config: &LayoutConfig) -> LayoutResult {
179 let pad = config.padding;
180
181 let mut top = pad;
183 let mut bottom = config.figure_height - pad;
184 let mut left = pad;
185 let mut right = config.figure_width - pad;
186
187 let title_area = if config.has_title {
189 let area = Rect::new(left, top, right - left, config.title_height);
190 top += config.title_height + pad;
191 Some(area)
192 } else {
193 None
194 };
195
196 let xlabel_area = if config.has_xlabel {
198 bottom -= config.xlabel_height;
199 let area = Rect::new(left, bottom, right - left, config.xlabel_height);
200 bottom -= pad;
201 Some(area)
202 } else {
203 None
204 };
205
206 let tick_bottom = config.tick_label_height + pad;
208 bottom -= tick_bottom;
209
210 let ylabel_area = if config.has_ylabel {
212 let area = Rect::new(left, top, config.ylabel_width, bottom - top);
213 left += config.ylabel_width + pad;
214 Some(area)
215 } else {
216 None
217 };
218
219 let tick_left = config.tick_label_max_width + pad;
221 left += tick_left;
222
223 let legend_area = if config.has_legend {
225 right -= config.legend_width;
226 let area = Rect::new(right, top, config.legend_width, bottom - top);
227 right -= pad;
228 Some(area)
229 } else {
230 None
231 };
232
233 let tick_right_overhang = config.tick_label_max_width * 0.5;
236 right -= tick_right_overhang;
237
238 let tick_top_overhang = config.tick_label_height * 0.5;
240 top += tick_top_overhang;
241
242 let plot_width = (right - left).max(config.min_plot_width);
244 let plot_height = (bottom - top).max(config.min_plot_height);
245
246 let actual_width = right - left;
249 let actual_height = bottom - top;
250
251 let plot_x = if plot_width > actual_width {
252 left - (plot_width - actual_width) / 2.0
253 } else {
254 left
255 };
256 let plot_y = if plot_height > actual_height {
257 top - (plot_height - actual_height) / 2.0
258 } else {
259 top
260 };
261
262 let plot_area = Rect::new(plot_x, plot_y, plot_width, plot_height);
263
264 let tick_label_margins = Margins {
265 top: tick_top_overhang,
266 right: tick_right_overhang,
267 bottom: tick_bottom,
268 left: tick_left,
269 };
270
271 LayoutResult {
272 plot_area,
273 title_area,
274 xlabel_area,
275 ylabel_area,
276 legend_area,
277 tick_label_margins,
278 }
279}
280
281pub fn compute_subplot_rects(
306 figure_width: f64,
307 figure_height: f64,
308 nrows: usize,
309 ncols: usize,
310 spacing: f64,
311 outer_padding: f64,
312) -> Vec<Rect> {
313 assert!(nrows > 0, "nrows must be at least 1");
314 assert!(ncols > 0, "ncols must be at least 1");
315
316 let total_h_spacing = spacing * (ncols as f64 - 1.0);
318 let total_v_spacing = spacing * (nrows as f64 - 1.0);
319
320 let avail_width = (figure_width - 2.0 * outer_padding - total_h_spacing).max(0.0);
322 let avail_height = (figure_height - 2.0 * outer_padding - total_v_spacing).max(0.0);
323
324 let cell_width = avail_width / ncols as f64;
325 let cell_height = avail_height / nrows as f64;
326
327 let mut rects = Vec::with_capacity(nrows * ncols);
328
329 for row in 0..nrows {
330 for col in 0..ncols {
331 let x = outer_padding + col as f64 * (cell_width + spacing);
332 let y = outer_padding + row as f64 * (cell_height + spacing);
333 rects.push(Rect::new(x, y, cell_width, cell_height));
334 }
335 }
336
337 rects
338}
339
340pub fn compute_layout_in_rect(cell: &Rect, config: &LayoutConfig) -> LayoutResult {
351 let mut local_config = config.clone();
352 local_config.figure_width = cell.width;
353 local_config.figure_height = cell.height;
354
355 let mut result = compute_layout(&local_config);
356
357 translate_rect(&mut result.plot_area, cell.x, cell.y);
359
360 if let Some(ref mut r) = result.title_area {
361 translate_rect(r, cell.x, cell.y);
362 }
363 if let Some(ref mut r) = result.xlabel_area {
364 translate_rect(r, cell.x, cell.y);
365 }
366 if let Some(ref mut r) = result.ylabel_area {
367 translate_rect(r, cell.x, cell.y);
368 }
369 if let Some(ref mut r) = result.legend_area {
370 translate_rect(r, cell.x, cell.y);
371 }
372
373 result
374}
375
376fn translate_rect(rect: &mut Rect, dx: f64, dy: f64) {
378 rect.x += dx;
379 rect.y += dy;
380}
381
382#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn assert_positive_rect(r: &Rect, label: &str) {
392 assert!(
393 r.width > 0.0 && r.height > 0.0,
394 "{label}: expected positive dimensions, got {w}x{h}",
395 w = r.width,
396 h = r.height,
397 );
398 }
399
400 #[test]
401 fn basic_layout_no_decorations() {
402 let config = LayoutConfig::new(800.0, 600.0);
403 let result = compute_layout(&config);
404
405 assert_positive_rect(&result.plot_area, "plot_area");
406 assert!(result.title_area.is_none());
407 assert!(result.xlabel_area.is_none());
408 assert!(result.ylabel_area.is_none());
409 assert!(result.legend_area.is_none());
410 }
411
412 #[test]
413 fn layout_with_all_decorations() {
414 let mut config = LayoutConfig::new(800.0, 600.0);
415 config.has_title = true;
416 config.has_xlabel = true;
417 config.has_ylabel = true;
418 config.has_legend = true;
419
420 let result = compute_layout(&config);
421
422 assert_positive_rect(&result.plot_area, "plot_area");
423
424 let title = result.title_area.as_ref().unwrap();
425 let xlabel = result.xlabel_area.as_ref().unwrap();
426 let ylabel = result.ylabel_area.as_ref().unwrap();
427 let legend = result.legend_area.as_ref().unwrap();
428
429 assert_positive_rect(title, "title");
430 assert_positive_rect(xlabel, "xlabel");
431 assert_positive_rect(ylabel, "ylabel");
432 assert_positive_rect(legend, "legend");
433
434 assert!(
436 title.bottom() <= result.plot_area.y,
437 "title bottom ({}) should be <= plot_area top ({})",
438 title.bottom(),
439 result.plot_area.y,
440 );
441
442 assert!(
444 xlabel.y >= result.plot_area.bottom(),
445 "xlabel top ({}) should be >= plot_area bottom ({})",
446 xlabel.y,
447 result.plot_area.bottom(),
448 );
449
450 assert!(
452 ylabel.right() <= result.plot_area.x,
453 "ylabel right ({}) should be <= plot_area left ({})",
454 ylabel.right(),
455 result.plot_area.x,
456 );
457 }
458
459 #[test]
460 fn plot_area_stays_within_figure() {
461 let mut config = LayoutConfig::new(800.0, 600.0);
462 config.has_title = true;
463 config.has_xlabel = true;
464 config.has_ylabel = true;
465 config.has_legend = true;
466
467 let result = compute_layout(&config);
468 let pa = &result.plot_area;
469
470 assert!(pa.x >= 0.0, "plot_area left edge is negative");
471 assert!(pa.y >= 0.0, "plot_area top edge is negative");
472 assert!(
473 pa.right() <= config.figure_width,
474 "plot_area right ({}) exceeds figure width ({})",
475 pa.right(),
476 config.figure_width,
477 );
478 assert!(
479 pa.bottom() <= config.figure_height,
480 "plot_area bottom ({}) exceeds figure height ({})",
481 pa.bottom(),
482 config.figure_height,
483 );
484 }
485
486 #[test]
487 fn small_figure_respects_minimums() {
488 let mut config = LayoutConfig::new(120.0, 100.0);
489 config.has_title = true;
490 config.has_xlabel = true;
491 config.has_ylabel = true;
492
493 let result = compute_layout(&config);
494 let pa = &result.plot_area;
495
496 assert!(
497 pa.width >= config.min_plot_width,
498 "plot_area width ({}) < min ({})",
499 pa.width,
500 config.min_plot_width,
501 );
502 assert!(
503 pa.height >= config.min_plot_height,
504 "plot_area height ({}) < min ({})",
505 pa.height,
506 config.min_plot_height,
507 );
508 }
509
510 #[test]
511 fn subplot_grid_basic() {
512 let rects = compute_subplot_rects(800.0, 600.0, 2, 3, 10.0, 20.0);
513 assert_eq!(rects.len(), 6);
514
515 for (i, r) in rects.iter().enumerate() {
517 assert_positive_rect(r, &format!("subplot[{i}]"));
518 }
519
520 assert!((rects[0].x - 20.0).abs() < 1e-9);
522 assert!((rects[0].y - 20.0).abs() < 1e-9);
523
524 assert!((rects[0].y - rects[1].y).abs() < 1e-9);
526 assert!((rects[0].height - rects[1].height).abs() < 1e-9);
527
528 assert!((rects[0].x - rects[3].x).abs() < 1e-9);
530 assert!((rects[0].width - rects[3].width).abs() < 1e-9);
531 }
532
533 #[test]
534 fn subplot_single_cell() {
535 let rects = compute_subplot_rects(800.0, 600.0, 1, 1, 10.0, 20.0);
536 assert_eq!(rects.len(), 1);
537
538 let r = &rects[0];
539 assert!((r.x - 20.0).abs() < 1e-9);
540 assert!((r.y - 20.0).abs() < 1e-9);
541 assert!((r.width - 760.0).abs() < 1e-9);
542 assert!((r.height - 560.0).abs() < 1e-9);
543 }
544
545 #[test]
546 fn subplot_cells_cover_figure() {
547 let rects = compute_subplot_rects(800.0, 600.0, 2, 2, 10.0, 15.0);
548
549 let last = &rects[3];
552 assert!(
553 (last.right() - (800.0 - 15.0)).abs() < 1e-9,
554 "last cell right ({}) != figure_width - padding ({})",
555 last.right(),
556 800.0 - 15.0,
557 );
558 assert!(
559 (last.bottom() - (600.0 - 15.0)).abs() < 1e-9,
560 "last cell bottom ({}) != figure_height - padding ({})",
561 last.bottom(),
562 600.0 - 15.0,
563 );
564 }
565
566 #[test]
567 #[should_panic(expected = "nrows must be at least 1")]
568 fn subplot_zero_rows_panics() {
569 compute_subplot_rects(800.0, 600.0, 0, 2, 10.0, 20.0);
570 }
571
572 #[test]
573 #[should_panic(expected = "ncols must be at least 1")]
574 fn subplot_zero_cols_panics() {
575 compute_subplot_rects(800.0, 600.0, 2, 0, 10.0, 20.0);
576 }
577
578 #[test]
579 fn layout_in_rect_translates_correctly() {
580 let cell = Rect::new(100.0, 50.0, 400.0, 300.0);
581 let config = LayoutConfig::new(400.0, 300.0);
582
583 let result = compute_layout_in_rect(&cell, &config);
584 let pa = &result.plot_area;
585
586 assert!(
588 pa.x >= cell.x,
589 "plot_area x ({}) < cell x ({})",
590 pa.x,
591 cell.x,
592 );
593 assert!(
594 pa.y >= cell.y,
595 "plot_area y ({}) < cell y ({})",
596 pa.y,
597 cell.y,
598 );
599 assert!(
600 pa.right() <= cell.right(),
601 "plot_area right ({}) > cell right ({})",
602 pa.right(),
603 cell.right(),
604 );
605 assert!(
606 pa.bottom() <= cell.bottom(),
607 "plot_area bottom ({}) > cell bottom ({})",
608 pa.bottom(),
609 cell.bottom(),
610 );
611 }
612
613 #[test]
614 fn margins_helpers() {
615 let m = Margins::new(10.0, 20.0, 30.0, 40.0);
616 assert!((m.horizontal() - 60.0).abs() < 1e-9);
617 assert!((m.vertical() - 40.0).abs() < 1e-9);
618
619 let u = Margins::uniform(15.0);
620 assert_eq!(u.top, 15.0);
621 assert_eq!(u.right, 15.0);
622 assert_eq!(u.bottom, 15.0);
623 assert_eq!(u.left, 15.0);
624 }
625
626 #[test]
627 fn default_margins_are_zero() {
628 let m = Margins::default();
629 assert_eq!(m.top, 0.0);
630 assert_eq!(m.right, 0.0);
631 assert_eq!(m.bottom, 0.0);
632 assert_eq!(m.left, 0.0);
633 }
634}