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 { top, right, bottom, left }
51 }
52
53 pub fn horizontal(&self) -> f64 {
55 self.left + self.right
56 }
57
58 pub fn vertical(&self) -> f64 {
60 self.top + self.bottom
61 }
62}
63
64#[derive(Debug, Clone)]
73pub struct LayoutResult {
74 pub plot_area: Rect,
76 pub title_area: Option<Rect>,
78 pub xlabel_area: Option<Rect>,
80 pub ylabel_area: Option<Rect>,
82 pub legend_area: Option<Rect>,
84 pub tick_label_margins: Margins,
86}
87
88#[derive(Debug, Clone)]
97pub struct LayoutConfig {
98 pub figure_width: f64,
100 pub figure_height: f64,
102 pub has_title: bool,
104 pub has_xlabel: bool,
106 pub has_ylabel: bool,
108 pub has_legend: bool,
110 pub title_height: f64,
112 pub xlabel_height: f64,
114 pub ylabel_width: f64,
116 pub tick_label_max_width: f64,
118 pub tick_label_height: f64,
120 pub legend_width: f64,
122 pub padding: f64,
124 pub min_plot_width: f64,
126 pub min_plot_height: f64,
128}
129
130impl LayoutConfig {
131 pub fn new(width: f64, height: f64) -> Self {
136 Self {
137 figure_width: width,
138 figure_height: height,
139 has_title: false,
140 has_xlabel: false,
141 has_ylabel: false,
142 has_legend: false,
143 title_height: 20.0,
144 xlabel_height: 16.0,
145 ylabel_width: 16.0,
146 tick_label_max_width: 40.0,
147 tick_label_height: 12.0,
148 legend_width: 80.0,
149 padding: 10.0,
150 min_plot_width: 60.0,
151 min_plot_height: 40.0,
152 }
153 }
154}
155
156pub fn compute_layout(config: &LayoutConfig) -> LayoutResult {
174 let pad = config.padding;
175
176 let mut top = pad;
178 let mut bottom = config.figure_height - pad;
179 let mut left = pad;
180 let mut right = config.figure_width - pad;
181
182 let title_area = if config.has_title {
184 let area = Rect::new(left, top, right - left, config.title_height);
185 top += config.title_height + pad;
186 Some(area)
187 } else {
188 None
189 };
190
191 let xlabel_area = if config.has_xlabel {
193 bottom -= config.xlabel_height;
194 let area = Rect::new(left, bottom, right - left, config.xlabel_height);
195 bottom -= pad;
196 Some(area)
197 } else {
198 None
199 };
200
201 let tick_bottom = config.tick_label_height + pad;
203 bottom -= tick_bottom;
204
205 let ylabel_area = if config.has_ylabel {
207 let area = Rect::new(left, top, config.ylabel_width, bottom - top);
208 left += config.ylabel_width + pad;
209 Some(area)
210 } else {
211 None
212 };
213
214 let tick_left = config.tick_label_max_width + pad;
216 left += tick_left;
217
218 let legend_area = if config.has_legend {
220 right -= config.legend_width;
221 let area = Rect::new(right, top, config.legend_width, bottom - top);
222 right -= pad;
223 Some(area)
224 } else {
225 None
226 };
227
228 let tick_right_overhang = config.tick_label_max_width * 0.5;
231 right -= tick_right_overhang;
232
233 let tick_top_overhang = config.tick_label_height * 0.5;
235 top += tick_top_overhang;
236
237 let plot_width = (right - left).max(config.min_plot_width);
239 let plot_height = (bottom - top).max(config.min_plot_height);
240
241 let actual_width = right - left;
244 let actual_height = bottom - top;
245
246 let plot_x = if plot_width > actual_width {
247 left - (plot_width - actual_width) / 2.0
248 } else {
249 left
250 };
251 let plot_y = if plot_height > actual_height {
252 top - (plot_height - actual_height) / 2.0
253 } else {
254 top
255 };
256
257 let plot_area = Rect::new(plot_x, plot_y, plot_width, plot_height);
258
259 let tick_label_margins = Margins {
260 top: tick_top_overhang,
261 right: tick_right_overhang,
262 bottom: tick_bottom,
263 left: tick_left,
264 };
265
266 LayoutResult {
267 plot_area,
268 title_area,
269 xlabel_area,
270 ylabel_area,
271 legend_area,
272 tick_label_margins,
273 }
274}
275
276pub fn compute_subplot_rects(
301 figure_width: f64,
302 figure_height: f64,
303 nrows: usize,
304 ncols: usize,
305 spacing: f64,
306 outer_padding: f64,
307) -> Vec<Rect> {
308 assert!(nrows > 0, "nrows must be at least 1");
309 assert!(ncols > 0, "ncols must be at least 1");
310
311 let total_h_spacing = spacing * (ncols as f64 - 1.0);
313 let total_v_spacing = spacing * (nrows as f64 - 1.0);
314
315 let avail_width = (figure_width - 2.0 * outer_padding - total_h_spacing).max(0.0);
317 let avail_height = (figure_height - 2.0 * outer_padding - total_v_spacing).max(0.0);
318
319 let cell_width = avail_width / ncols as f64;
320 let cell_height = avail_height / nrows as f64;
321
322 let mut rects = Vec::with_capacity(nrows * ncols);
323
324 for row in 0..nrows {
325 for col in 0..ncols {
326 let x = outer_padding + col as f64 * (cell_width + spacing);
327 let y = outer_padding + row as f64 * (cell_height + spacing);
328 rects.push(Rect::new(x, y, cell_width, cell_height));
329 }
330 }
331
332 rects
333}
334
335pub fn compute_layout_in_rect(cell: &Rect, config: &LayoutConfig) -> LayoutResult {
346 let mut local_config = config.clone();
347 local_config.figure_width = cell.width;
348 local_config.figure_height = cell.height;
349
350 let mut result = compute_layout(&local_config);
351
352 translate_rect(&mut result.plot_area, cell.x, cell.y);
354
355 if let Some(ref mut r) = result.title_area {
356 translate_rect(r, cell.x, cell.y);
357 }
358 if let Some(ref mut r) = result.xlabel_area {
359 translate_rect(r, cell.x, cell.y);
360 }
361 if let Some(ref mut r) = result.ylabel_area {
362 translate_rect(r, cell.x, cell.y);
363 }
364 if let Some(ref mut r) = result.legend_area {
365 translate_rect(r, cell.x, cell.y);
366 }
367
368 result
369}
370
371fn translate_rect(rect: &mut Rect, dx: f64, dy: f64) {
373 rect.x += dx;
374 rect.y += dy;
375}
376
377#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn assert_positive_rect(r: &Rect, label: &str) {
387 assert!(
388 r.width > 0.0 && r.height > 0.0,
389 "{label}: expected positive dimensions, got {w}x{h}",
390 w = r.width,
391 h = r.height,
392 );
393 }
394
395 #[test]
396 fn basic_layout_no_decorations() {
397 let config = LayoutConfig::new(800.0, 600.0);
398 let result = compute_layout(&config);
399
400 assert_positive_rect(&result.plot_area, "plot_area");
401 assert!(result.title_area.is_none());
402 assert!(result.xlabel_area.is_none());
403 assert!(result.ylabel_area.is_none());
404 assert!(result.legend_area.is_none());
405 }
406
407 #[test]
408 fn layout_with_all_decorations() {
409 let mut config = LayoutConfig::new(800.0, 600.0);
410 config.has_title = true;
411 config.has_xlabel = true;
412 config.has_ylabel = true;
413 config.has_legend = true;
414
415 let result = compute_layout(&config);
416
417 assert_positive_rect(&result.plot_area, "plot_area");
418
419 let title = result.title_area.as_ref().unwrap();
420 let xlabel = result.xlabel_area.as_ref().unwrap();
421 let ylabel = result.ylabel_area.as_ref().unwrap();
422 let legend = result.legend_area.as_ref().unwrap();
423
424 assert_positive_rect(title, "title");
425 assert_positive_rect(xlabel, "xlabel");
426 assert_positive_rect(ylabel, "ylabel");
427 assert_positive_rect(legend, "legend");
428
429 assert!(
431 title.bottom() <= result.plot_area.y,
432 "title bottom ({}) should be <= plot_area top ({})",
433 title.bottom(),
434 result.plot_area.y,
435 );
436
437 assert!(
439 xlabel.y >= result.plot_area.bottom(),
440 "xlabel top ({}) should be >= plot_area bottom ({})",
441 xlabel.y,
442 result.plot_area.bottom(),
443 );
444
445 assert!(
447 ylabel.right() <= result.plot_area.x,
448 "ylabel right ({}) should be <= plot_area left ({})",
449 ylabel.right(),
450 result.plot_area.x,
451 );
452 }
453
454 #[test]
455 fn plot_area_stays_within_figure() {
456 let mut config = LayoutConfig::new(800.0, 600.0);
457 config.has_title = true;
458 config.has_xlabel = true;
459 config.has_ylabel = true;
460 config.has_legend = true;
461
462 let result = compute_layout(&config);
463 let pa = &result.plot_area;
464
465 assert!(pa.x >= 0.0, "plot_area left edge is negative");
466 assert!(pa.y >= 0.0, "plot_area top edge is negative");
467 assert!(
468 pa.right() <= config.figure_width,
469 "plot_area right ({}) exceeds figure width ({})",
470 pa.right(),
471 config.figure_width,
472 );
473 assert!(
474 pa.bottom() <= config.figure_height,
475 "plot_area bottom ({}) exceeds figure height ({})",
476 pa.bottom(),
477 config.figure_height,
478 );
479 }
480
481 #[test]
482 fn small_figure_respects_minimums() {
483 let mut config = LayoutConfig::new(120.0, 100.0);
484 config.has_title = true;
485 config.has_xlabel = true;
486 config.has_ylabel = true;
487
488 let result = compute_layout(&config);
489 let pa = &result.plot_area;
490
491 assert!(
492 pa.width >= config.min_plot_width,
493 "plot_area width ({}) < min ({})",
494 pa.width,
495 config.min_plot_width,
496 );
497 assert!(
498 pa.height >= config.min_plot_height,
499 "plot_area height ({}) < min ({})",
500 pa.height,
501 config.min_plot_height,
502 );
503 }
504
505 #[test]
506 fn subplot_grid_basic() {
507 let rects = compute_subplot_rects(800.0, 600.0, 2, 3, 10.0, 20.0);
508 assert_eq!(rects.len(), 6);
509
510 for (i, r) in rects.iter().enumerate() {
512 assert_positive_rect(r, &format!("subplot[{i}]"));
513 }
514
515 assert!((rects[0].x - 20.0).abs() < 1e-9);
517 assert!((rects[0].y - 20.0).abs() < 1e-9);
518
519 assert!((rects[0].y - rects[1].y).abs() < 1e-9);
521 assert!((rects[0].height - rects[1].height).abs() < 1e-9);
522
523 assert!((rects[0].x - rects[3].x).abs() < 1e-9);
525 assert!((rects[0].width - rects[3].width).abs() < 1e-9);
526 }
527
528 #[test]
529 fn subplot_single_cell() {
530 let rects = compute_subplot_rects(800.0, 600.0, 1, 1, 10.0, 20.0);
531 assert_eq!(rects.len(), 1);
532
533 let r = &rects[0];
534 assert!((r.x - 20.0).abs() < 1e-9);
535 assert!((r.y - 20.0).abs() < 1e-9);
536 assert!((r.width - 760.0).abs() < 1e-9);
537 assert!((r.height - 560.0).abs() < 1e-9);
538 }
539
540 #[test]
541 fn subplot_cells_cover_figure() {
542 let rects = compute_subplot_rects(800.0, 600.0, 2, 2, 10.0, 15.0);
543
544 let last = &rects[3];
547 assert!(
548 (last.right() - (800.0 - 15.0)).abs() < 1e-9,
549 "last cell right ({}) != figure_width - padding ({})",
550 last.right(),
551 800.0 - 15.0,
552 );
553 assert!(
554 (last.bottom() - (600.0 - 15.0)).abs() < 1e-9,
555 "last cell bottom ({}) != figure_height - padding ({})",
556 last.bottom(),
557 600.0 - 15.0,
558 );
559 }
560
561 #[test]
562 #[should_panic(expected = "nrows must be at least 1")]
563 fn subplot_zero_rows_panics() {
564 compute_subplot_rects(800.0, 600.0, 0, 2, 10.0, 20.0);
565 }
566
567 #[test]
568 #[should_panic(expected = "ncols must be at least 1")]
569 fn subplot_zero_cols_panics() {
570 compute_subplot_rects(800.0, 600.0, 2, 0, 10.0, 20.0);
571 }
572
573 #[test]
574 fn layout_in_rect_translates_correctly() {
575 let cell = Rect::new(100.0, 50.0, 400.0, 300.0);
576 let config = LayoutConfig::new(400.0, 300.0);
577
578 let result = compute_layout_in_rect(&cell, &config);
579 let pa = &result.plot_area;
580
581 assert!(
583 pa.x >= cell.x,
584 "plot_area x ({}) < cell x ({})",
585 pa.x,
586 cell.x,
587 );
588 assert!(
589 pa.y >= cell.y,
590 "plot_area y ({}) < cell y ({})",
591 pa.y,
592 cell.y,
593 );
594 assert!(
595 pa.right() <= cell.right(),
596 "plot_area right ({}) > cell right ({})",
597 pa.right(),
598 cell.right(),
599 );
600 assert!(
601 pa.bottom() <= cell.bottom(),
602 "plot_area bottom ({}) > cell bottom ({})",
603 pa.bottom(),
604 cell.bottom(),
605 );
606 }
607
608 #[test]
609 fn margins_helpers() {
610 let m = Margins::new(10.0, 20.0, 30.0, 40.0);
611 assert!((m.horizontal() - 60.0).abs() < 1e-9);
612 assert!((m.vertical() - 40.0).abs() < 1e-9);
613
614 let u = Margins::uniform(15.0);
615 assert_eq!(u.top, 15.0);
616 assert_eq!(u.right, 15.0);
617 assert_eq!(u.bottom, 15.0);
618 assert_eq!(u.left, 15.0);
619 }
620
621 #[test]
622 fn default_margins_are_zero() {
623 let m = Margins::default();
624 assert_eq!(m.top, 0.0);
625 assert_eq!(m.right, 0.0);
626 assert_eq!(m.bottom, 0.0);
627 assert_eq!(m.left, 0.0);
628 }
629}