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 { top, right, bottom, left }
51    }
52
53    /// Returns the total horizontal margin (left + right).
54    pub fn horizontal(&self) -> f64 {
55        self.left + self.right
56    }
57
58    /// Returns the total vertical margin (top + bottom).
59    pub fn vertical(&self) -> f64 {
60        self.top + self.bottom
61    }
62}
63
64// ---------------------------------------------------------------------------
65// LayoutResult
66// ---------------------------------------------------------------------------
67
68/// Result of the layout computation.
69///
70/// All rectangles use figure-pixel coordinates with the origin at the top-left
71/// corner and y increasing downward.
72#[derive(Debug, Clone)]
73pub struct LayoutResult {
74    /// The plot/data area rectangle in figure-pixel coordinates.
75    pub plot_area: Rect,
76    /// Space reserved at top for the title, if present.
77    pub title_area: Option<Rect>,
78    /// Space reserved at bottom for the x-axis label, if present.
79    pub xlabel_area: Option<Rect>,
80    /// Space reserved at left for the y-axis label, if present.
81    pub ylabel_area: Option<Rect>,
82    /// Space reserved at right for the legend, if present.
83    pub legend_area: Option<Rect>,
84    /// Accumulated margins consumed by tick labels on each side.
85    pub tick_label_margins: Margins,
86}
87
88// ---------------------------------------------------------------------------
89// LayoutConfig
90// ---------------------------------------------------------------------------
91
92/// Configuration for layout computation.
93///
94/// Callers set the figure dimensions and declare which decorations are present;
95/// the layout engine then carves out non-overlapping rectangles for each element.
96#[derive(Debug, Clone)]
97pub struct LayoutConfig {
98    /// Total figure width in pixels.
99    pub figure_width: f64,
100    /// Total figure height in pixels.
101    pub figure_height: f64,
102    /// Whether a title is displayed above the plot.
103    pub has_title: bool,
104    /// Whether an x-axis label is displayed below the plot.
105    pub has_xlabel: bool,
106    /// Whether a y-axis label is displayed to the left of the plot.
107    pub has_ylabel: bool,
108    /// Whether a legend box is displayed to the right of the plot.
109    pub has_legend: bool,
110    /// Height of the title text in pixels.
111    pub title_height: f64,
112    /// Height of the x-axis label text in pixels.
113    pub xlabel_height: f64,
114    /// Width of the y-axis label text in pixels (measured along the rotated axis).
115    pub ylabel_width: f64,
116    /// Maximum width of any y-axis tick label in pixels.
117    pub tick_label_max_width: f64,
118    /// Height of a single tick label line in pixels.
119    pub tick_label_height: f64,
120    /// Width allocated for the legend box in pixels.
121    pub legend_width: f64,
122    /// General padding between layout elements in pixels.
123    pub padding: f64,
124    /// Minimum plot area width; layout will not shrink below this.
125    pub min_plot_width: f64,
126    /// Minimum plot area height; layout will not shrink below this.
127    pub min_plot_height: f64,
128}
129
130impl LayoutConfig {
131    /// Creates a configuration with sensible defaults for the given figure size.
132    ///
133    /// All decoration flags default to `false`; callers should enable the ones
134    /// they need before passing the config to [`compute_layout`].
135    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
156// ---------------------------------------------------------------------------
157// compute_layout
158// ---------------------------------------------------------------------------
159
160/// Computes the layout for a single axes within a figure.
161///
162/// The algorithm works inward from the figure edges in the following order:
163///
164/// 1. Outer padding on all four sides.
165/// 2. **Top:** title (if present), then a gap.
166/// 3. **Bottom:** x-axis label (if present), then x-axis tick labels, then a gap.
167/// 4. **Left:** y-axis label (if present), then y-axis tick labels, then a gap.
168/// 5. **Right:** legend (if present), then a gap.
169///
170/// Whatever remains in the center becomes the `plot_area`. If the remaining
171/// space is smaller than the configured minimums the plot area is clamped so
172/// that content is never collapsed to zero.
173pub fn compute_layout(config: &LayoutConfig) -> LayoutResult {
174    let pad = config.padding;
175
176    // Start with the full figure area, then shrink inward.
177    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    // -- Title (top) --------------------------------------------------------
183    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    // -- X-axis label (bottom) ----------------------------------------------
192    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    // -- X-axis tick labels (bottom) ----------------------------------------
202    let tick_bottom = config.tick_label_height + pad;
203    bottom -= tick_bottom;
204
205    // -- Y-axis label (left) ------------------------------------------------
206    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    // -- Y-axis tick labels (left) ------------------------------------------
215    let tick_left = config.tick_label_max_width + pad;
216    left += tick_left;
217
218    // -- Legend (right) -----------------------------------------------------
219    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    // -- Small padding for right-side tick overhang -------------------------
229    // Even without a legend, the rightmost tick label can overhang slightly.
230    let tick_right_overhang = config.tick_label_max_width * 0.5;
231    right -= tick_right_overhang;
232
233    // -- Small padding for top tick overhang --------------------------------
234    let tick_top_overhang = config.tick_label_height * 0.5;
235    top += tick_top_overhang;
236
237    // -- Clamp to minimum sizes ---------------------------------------------
238    let plot_width = (right - left).max(config.min_plot_width);
239    let plot_height = (bottom - top).max(config.min_plot_height);
240
241    // If we had to expand to meet minimums, center the expanded area in the
242    // available space.
243    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
276// ---------------------------------------------------------------------------
277// compute_subplot_rects
278// ---------------------------------------------------------------------------
279
280/// Computes subplot positions for a grid of axes.
281///
282/// Returns a `Vec<Rect>` with one entry per subplot cell in **row-major order**
283/// (left to right, top to bottom). Each rectangle represents the total
284/// available area for that subplot — callers should run [`compute_layout`] on
285/// each rect individually to determine the inner plot area and decoration
286/// positions.
287///
288/// # Arguments
289///
290/// * `figure_width`  — Total figure width in pixels.
291/// * `figure_height` — Total figure height in pixels.
292/// * `nrows`         — Number of rows in the subplot grid.
293/// * `ncols`         — Number of columns in the subplot grid.
294/// * `spacing`       — Gap between adjacent subplots in pixels.
295/// * `outer_padding` — Padding between the figure edges and the outermost subplots.
296///
297/// # Panics
298///
299/// Panics if `nrows` or `ncols` is zero.
300pub 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    // Total space consumed by inter-cell gaps.
312    let total_h_spacing = spacing * (ncols as f64 - 1.0);
313    let total_v_spacing = spacing * (nrows as f64 - 1.0);
314
315    // Space available for all cells after removing outer padding and gaps.
316    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
335// ---------------------------------------------------------------------------
336// compute_layout_in_rect
337// ---------------------------------------------------------------------------
338
339/// Convenience wrapper that runs [`compute_layout`] within a specific
340/// rectangle (e.g., one cell of a subplot grid).
341///
342/// The returned [`LayoutResult`] uses the **same coordinate system** as the
343/// input `cell` — that is, all positions are in figure-pixel coordinates, not
344/// relative to the cell origin.
345pub 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 everything from local (0,0) to the cell's position.
353    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
371/// Shifts a rectangle by `(dx, dy)`.
372fn translate_rect(rect: &mut Rect, dx: f64, dy: f64) {
373    rect.x += dx;
374    rect.y += dy;
375}
376
377// ---------------------------------------------------------------------------
378// Tests
379// ---------------------------------------------------------------------------
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    /// A small helper that asserts a rect has positive dimensions.
386    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        // Title should be above the plot area.
430        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        // X-axis label should be below the plot area.
438        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        // Y-axis label should be to the left of the plot area.
446        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        // All rects should have positive dimensions.
511        for (i, r) in rects.iter().enumerate() {
512            assert_positive_rect(r, &format!("subplot[{i}]"));
513        }
514
515        // First cell starts at the outer padding.
516        assert!((rects[0].x - 20.0).abs() < 1e-9);
517        assert!((rects[0].y - 20.0).abs() < 1e-9);
518
519        // Cells in the same row should have the same y and height.
520        assert!((rects[0].y - rects[1].y).abs() < 1e-9);
521        assert!((rects[0].height - rects[1].height).abs() < 1e-9);
522
523        // Cells in the same column should have the same x and width.
524        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        // Last cell's right/bottom edge should align with figure_width/height
545        // minus outer_padding.
546        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        // The plot area must be inside the cell bounds.
582        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}