Skip to main content

plotkit_core/
layout.rs

1//! Layout engine for computing plot area and margins.
2//!
3//! This module determines where the plot area, title, axis labels, tick labels,
4//! and legend are positioned within the figure dimensions. It implements a
5//! mini tight-layout algorithm to prevent clipping and produce well-spaced plots.
6
7use crate::primitives::Rect;
8
9// ---------------------------------------------------------------------------
10// Margins
11// ---------------------------------------------------------------------------
12
13/// Margins in pixels around the plot area.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Margins {
16    /// Space above the plot area.
17    pub top: f64,
18    /// Space to the right of the plot area.
19    pub right: f64,
20    /// Space below the plot area.
21    pub bottom: f64,
22    /// Space to the left of the plot area.
23    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    /// Creates margins with the same value on all four sides.
39    pub fn uniform(value: f64) -> Self {
40        Self {
41            top: value,
42            right: value,
43            bottom: value,
44            left: value,
45        }
46    }
47
48    /// Creates margins with specified values for each side.
49    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    /// Returns the total horizontal margin (left + right).
59    pub fn horizontal(&self) -> f64 {
60        self.left + self.right
61    }
62
63    /// Returns the total vertical margin (top + bottom).
64    pub fn vertical(&self) -> f64 {
65        self.top + self.bottom
66    }
67}
68
69// ---------------------------------------------------------------------------
70// LayoutResult
71// ---------------------------------------------------------------------------
72
73/// Result of the layout computation.
74///
75/// All rectangles use figure-pixel coordinates with the origin at the top-left
76/// corner and y increasing downward.
77#[derive(Debug, Clone)]
78pub struct LayoutResult {
79    /// The plot/data area rectangle in figure-pixel coordinates.
80    pub plot_area: Rect,
81    /// Space reserved at top for the title, if present.
82    pub title_area: Option<Rect>,
83    /// Space reserved at bottom for the x-axis label, if present.
84    pub xlabel_area: Option<Rect>,
85    /// Space reserved at left for the y-axis label, if present.
86    pub ylabel_area: Option<Rect>,
87    /// Space reserved at right for the legend, if present.
88    pub legend_area: Option<Rect>,
89    /// Accumulated margins consumed by tick labels on each side.
90    pub tick_label_margins: Margins,
91}
92
93// ---------------------------------------------------------------------------
94// LayoutConfig
95// ---------------------------------------------------------------------------
96
97/// Configuration for layout computation.
98///
99/// Callers set the figure dimensions and declare which decorations are present;
100/// the layout engine then carves out non-overlapping rectangles for each element.
101#[derive(Debug, Clone)]
102pub struct LayoutConfig {
103    /// Total figure width in pixels.
104    pub figure_width: f64,
105    /// Total figure height in pixels.
106    pub figure_height: f64,
107    /// Whether a title is displayed above the plot.
108    pub has_title: bool,
109    /// Whether an x-axis label is displayed below the plot.
110    pub has_xlabel: bool,
111    /// Whether a y-axis label is displayed to the left of the plot.
112    pub has_ylabel: bool,
113    /// Whether a legend box is displayed to the right of the plot.
114    pub has_legend: bool,
115    /// Height of the title text in pixels.
116    pub title_height: f64,
117    /// Height of the x-axis label text in pixels.
118    pub xlabel_height: f64,
119    /// Width of the y-axis label text in pixels (measured along the rotated axis).
120    pub ylabel_width: f64,
121    /// Maximum width of any y-axis tick label in pixels.
122    pub tick_label_max_width: f64,
123    /// Height of a single tick label line in pixels.
124    pub tick_label_height: f64,
125    /// Width allocated for the legend box in pixels.
126    pub legend_width: f64,
127    /// General padding between layout elements in pixels.
128    pub padding: f64,
129    /// Minimum plot area width; layout will not shrink below this.
130    pub min_plot_width: f64,
131    /// Minimum plot area height; layout will not shrink below this.
132    pub min_plot_height: f64,
133}
134
135impl LayoutConfig {
136    /// Creates a configuration with sensible defaults for the given figure size.
137    ///
138    /// All decoration flags default to `false`; callers should enable the ones
139    /// they need before passing the config to [`compute_layout`].
140    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
161// ---------------------------------------------------------------------------
162// compute_layout
163// ---------------------------------------------------------------------------
164
165/// Computes the layout for a single axes within a figure.
166///
167/// The algorithm works inward from the figure edges in the following order:
168///
169/// 1. Outer padding on all four sides.
170/// 2. **Top:** title (if present), then a gap.
171/// 3. **Bottom:** x-axis label (if present), then x-axis tick labels, then a gap.
172/// 4. **Left:** y-axis label (if present), then y-axis tick labels, then a gap.
173/// 5. **Right:** legend (if present), then a gap.
174///
175/// Whatever remains in the center becomes the `plot_area`. If the remaining
176/// space is smaller than the configured minimums the plot area is clamped so
177/// that content is never collapsed to zero.
178pub fn compute_layout(config: &LayoutConfig) -> LayoutResult {
179    let pad = config.padding;
180
181    // Start with the full figure area, then shrink inward.
182    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    // -- Title (top) --------------------------------------------------------
188    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    // -- X-axis label (bottom) ----------------------------------------------
197    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    // -- X-axis tick labels (bottom) ----------------------------------------
207    let tick_bottom = config.tick_label_height + pad;
208    bottom -= tick_bottom;
209
210    // -- Y-axis label (left) ------------------------------------------------
211    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    // -- Y-axis tick labels (left) ------------------------------------------
220    let tick_left = config.tick_label_max_width + pad;
221    left += tick_left;
222
223    // -- Legend (right) -----------------------------------------------------
224    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    // -- Small padding for right-side tick overhang -------------------------
234    // Even without a legend, the rightmost tick label can overhang slightly.
235    let tick_right_overhang = config.tick_label_max_width * 0.5;
236    right -= tick_right_overhang;
237
238    // -- Small padding for top tick overhang --------------------------------
239    let tick_top_overhang = config.tick_label_height * 0.5;
240    top += tick_top_overhang;
241
242    // -- Clamp to minimum sizes ---------------------------------------------
243    let plot_width = (right - left).max(config.min_plot_width);
244    let plot_height = (bottom - top).max(config.min_plot_height);
245
246    // If we had to expand to meet minimums, center the expanded area in the
247    // available space.
248    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
281// ---------------------------------------------------------------------------
282// compute_subplot_rects
283// ---------------------------------------------------------------------------
284
285/// Computes subplot positions for a grid of axes.
286///
287/// Returns a `Vec<Rect>` with one entry per subplot cell in **row-major order**
288/// (left to right, top to bottom). Each rectangle represents the total
289/// available area for that subplot — callers should run [`compute_layout`] on
290/// each rect individually to determine the inner plot area and decoration
291/// positions.
292///
293/// # Arguments
294///
295/// * `figure_width`  — Total figure width in pixels.
296/// * `figure_height` — Total figure height in pixels.
297/// * `nrows`         — Number of rows in the subplot grid.
298/// * `ncols`         — Number of columns in the subplot grid.
299/// * `spacing`       — Gap between adjacent subplots in pixels.
300/// * `outer_padding` — Padding between the figure edges and the outermost subplots.
301///
302/// # Panics
303///
304/// Panics if `nrows` or `ncols` is zero.
305pub 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    // Total space consumed by inter-cell gaps.
317    let total_h_spacing = spacing * (ncols as f64 - 1.0);
318    let total_v_spacing = spacing * (nrows as f64 - 1.0);
319
320    // Space available for all cells after removing outer padding and gaps.
321    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
340// ---------------------------------------------------------------------------
341// compute_layout_in_rect
342// ---------------------------------------------------------------------------
343
344/// Convenience wrapper that runs [`compute_layout`] within a specific
345/// rectangle (e.g., one cell of a subplot grid).
346///
347/// The returned [`LayoutResult`] uses the **same coordinate system** as the
348/// input `cell` — that is, all positions are in figure-pixel coordinates, not
349/// relative to the cell origin.
350pub 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 everything from local (0,0) to the cell's position.
358    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
376/// Shifts a rectangle by `(dx, dy)`.
377fn translate_rect(rect: &mut Rect, dx: f64, dy: f64) {
378    rect.x += dx;
379    rect.y += dy;
380}
381
382// ---------------------------------------------------------------------------
383// Tests
384// ---------------------------------------------------------------------------
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    /// A small helper that asserts a rect has positive dimensions.
391    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        // Title should be above the plot area.
435        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        // X-axis label should be below the plot area.
443        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        // Y-axis label should be to the left of the plot area.
451        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        // All rects should have positive dimensions.
516        for (i, r) in rects.iter().enumerate() {
517            assert_positive_rect(r, &format!("subplot[{i}]"));
518        }
519
520        // First cell starts at the outer padding.
521        assert!((rects[0].x - 20.0).abs() < 1e-9);
522        assert!((rects[0].y - 20.0).abs() < 1e-9);
523
524        // Cells in the same row should have the same y and height.
525        assert!((rects[0].y - rects[1].y).abs() < 1e-9);
526        assert!((rects[0].height - rects[1].height).abs() < 1e-9);
527
528        // Cells in the same column should have the same x and width.
529        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        // Last cell's right/bottom edge should align with figure_width/height
550        // minus outer_padding.
551        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        // The plot area must be inside the cell bounds.
587        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}