Skip to main content

chartml_core/layout/
margins.rs

1/// Chart margins in pixels.
2#[derive(Debug, Clone, Copy)]
3pub struct Margins {
4    pub top: f64,
5    pub right: f64,
6    pub bottom: f64,
7    pub left: f64,
8}
9
10impl Margins {
11    pub fn new(top: f64, right: f64, bottom: f64, left: f64) -> Self {
12        Self { top, right, bottom, left }
13    }
14
15    /// Calculate the inner chart width after margins.
16    pub fn inner_width(&self, total_width: f64) -> f64 {
17        (total_width - self.left - self.right).max(0.0)
18    }
19
20    /// Calculate the inner chart height after margins.
21    pub fn inner_height(&self, total_height: f64) -> f64 {
22        (total_height - self.top - self.bottom).max(0.0)
23    }
24}
25
26impl Default for Margins {
27    fn default() -> Self {
28        Self { top: 20.0, right: 30.0, bottom: 40.0, left: 70.0 }
29    }
30}
31
32/// Configuration for margin calculation.
33pub struct MarginConfig {
34    pub has_title: bool,
35    pub has_x_axis_label: bool,
36    pub has_y_axis_label: bool,
37    pub has_right_axis: bool,
38    /// Actual legend height in pixels (0.0 when no legend is present).
39    /// This should be pre-computed via `calculate_legend_layout().total_height`
40    /// so that multi-row legends get proper bottom margin space.
41    pub legend_height: f64,
42    pub y_tick_labels: Vec<String>,
43    pub right_tick_labels: Vec<String>,
44    pub x_label_strategy_margin: f64,
45    pub max_left_margin: f64,
46    pub max_right_margin: f64,
47    /// Total SVG height — used to scale bottom margin for small charts.
48    pub chart_height: f64,
49    /// Text metrics used to measure numeric tick labels (left/right axes).
50    /// Defaults to the legacy 12px sans calibration; override via
51    /// `TextMetrics::from_theme_tick_value(theme)` when the theme overrides
52    /// numeric typography.
53    pub tick_value_metrics: super::labels::TextMetrics,
54    /// Text metrics used to reserve room for the rotated Y-axis label.
55    /// Defaults to legacy.
56    pub axis_label_metrics: super::labels::TextMetrics,
57}
58
59impl Default for MarginConfig {
60    fn default() -> Self {
61        Self {
62            has_title: false,
63            has_x_axis_label: false,
64            has_y_axis_label: false,
65            has_right_axis: false,
66            legend_height: 0.0,
67            y_tick_labels: Vec::new(),
68            right_tick_labels: Vec::new(),
69            x_label_strategy_margin: 0.0,
70            max_left_margin: 250.0,
71            max_right_margin: 250.0,
72            chart_height: 400.0,
73            tick_value_metrics: super::labels::TextMetrics::default(),
74            axis_label_metrics: super::labels::TextMetrics::default(),
75        }
76    }
77}
78
79/// Calculate chart margins based on configuration.
80///
81/// Algorithm:
82/// - Top: 20px (title is rendered as HTML outside the SVG)
83/// - Left: max(tick_label_widths) + 15px buffer, or tick_width + 33px when y-axis
84///   label is present (to fit rotated label + gap), capped at max_left_margin
85/// - Right: 30px base, or max(right_label_widths) + 44px if right axis present,
86///   capped at max_right_margin
87/// - Bottom: 40px base + x_label_strategy_margin (rotation) + 20px if x-axis label
88///   + legend_height + 8px gap when legend is present
89pub fn calculate_margins(config: &MarginConfig) -> Margins {
90    use super::labels::measure_text;
91
92    // Top margin — matches JS d3CartesianChart.js marginTop=20 (title is rendered as HTML outside SVG)
93    let top = 20.0;
94
95    // Left margin: based on Y-axis tick label widths
96    let max_y_label_width = config.y_tick_labels.iter()
97        .map(|l| measure_text(l, &config.tick_value_metrics))
98        .fold(0.0_f64, f64::max);
99    let tick_padding = 15.0;
100    let left_base = if max_y_label_width > 0.0 {
101        max_y_label_width + tick_padding
102    } else {
103        70.0 // matches JS d3CartesianChart.js default marginLeft
104    };
105    // When a y-axis label is present (rotated -90°), ensure the left margin
106    // accommodates: tick_label_max_width + tick_padding (15px) + gap (4px)
107    // + axis_label_width (rotated text height ≈ font size + padding).
108    // This prevents the rotated axis label from overlapping the tick labels.
109    let left = if config.has_y_axis_label {
110        // Base legacy allowance is 14px; when the theme scales the axis
111        // label font up, grow the reservation in proportion so the rotated
112        // label still clears the tick labels.
113        let axis_label_width = if config.axis_label_metrics.is_legacy_default() {
114            14.0_f64
115        } else {
116            (config.axis_label_metrics.font_size_px + 2.0).max(14.0)
117        };
118        let gap = 4.0_f64;
119        let min_with_label = max_y_label_width + tick_padding + gap + axis_label_width;
120        left_base.max(min_with_label)
121    } else {
122        left_base
123    }.min(config.max_left_margin);
124
125    // Right margin
126    let right = if config.has_right_axis {
127        let max_right_width = config.right_tick_labels.iter()
128            .map(|l| measure_text(l, &config.tick_value_metrics))
129            .fold(0.0_f64, f64::max);
130        // 24px for tick+gap, +20px for axis title label
131        (max_right_width + 24.0 + 20.0)
132            .min(config.max_right_margin)
133    } else {
134        30.0 // matches JS d3CartesianChart.js default marginRight
135    };
136
137    // Bottom margin: 40px base covers tick marks (+5px) and horizontal label text (+18px)
138    // with some padding.  For small charts (< 300px tall), scale proportionally to
139    // avoid the base consuming too much of the chart height.
140    // The rotation margin from x_label_strategy_margin adds the vertical descent of
141    // rotated labels beyond the horizontal baseline.
142    let base_bottom = if config.chart_height < 300.0 {
143        // Scale: at 150px tall => ~25px base; at 300px => 40px base.
144        // This keeps horizontal labels visible while leaving room for the plot.
145        (config.chart_height * 0.16).clamp(20.0, 40.0)
146    } else {
147        40.0
148    };
149    let bottom = base_bottom
150        + config.x_label_strategy_margin
151        + if config.has_x_axis_label { 20.0 } else { 0.0 }
152        + if config.legend_height > 0.0 { config.legend_height + 8.0 } else { 0.0 };
153
154    Margins { top, right, bottom, left }
155}
156
157#[cfg(test)]
158mod tests {
159    #![allow(clippy::unwrap_used)]
160    use super::*;
161
162    #[test]
163    fn default_margins() {
164        let m = Margins::default();
165        assert_eq!(m.top, 20.0);
166        assert_eq!(m.right, 30.0);
167        assert_eq!(m.bottom, 40.0);
168        assert_eq!(m.left, 70.0);
169    }
170
171    #[test]
172    fn margins_inner_dimensions() {
173        let m = Margins::new(10.0, 20.0, 30.0, 40.0);
174        assert!((m.inner_width(800.0) - 740.0).abs() < f64::EPSILON);
175        assert!((m.inner_height(600.0) - 560.0).abs() < f64::EPSILON);
176    }
177
178    #[test]
179    fn margins_inner_dimensions_clamp_to_zero() {
180        let m = Margins::new(300.0, 300.0, 300.0, 300.0);
181        assert_eq!(m.inner_width(100.0), 0.0);
182        assert_eq!(m.inner_height(100.0), 0.0);
183    }
184
185    #[test]
186    fn margins_with_title() {
187        let config = MarginConfig {
188            has_title: true,
189            ..Default::default()
190        };
191        let m = calculate_margins(&config);
192        assert_eq!(m.top, 20.0); // title is rendered as HTML outside SVG — no extra margin needed
193    }
194
195    #[test]
196    fn margins_without_title() {
197        let config = MarginConfig::default();
198        let m = calculate_margins(&config);
199        assert_eq!(m.top, 20.0);
200    }
201
202    #[test]
203    fn margins_with_single_row_legend() {
204        let config = MarginConfig {
205            legend_height: 20.0, // single row
206            ..Default::default()
207        };
208        let m = calculate_margins(&config);
209        assert_eq!(m.bottom, 68.0); // 40 + 20 + 8
210    }
211
212    #[test]
213    fn margins_with_multi_row_legend() {
214        let config = MarginConfig {
215            legend_height: 60.0, // 3 rows × 20px
216            ..Default::default()
217        };
218        let m = calculate_margins(&config);
219        assert_eq!(m.bottom, 108.0); // 40 + 60 + 8
220    }
221
222    #[test]
223    fn margins_with_y_labels() {
224        use crate::layout::labels::approximate_text_width;
225        let config = MarginConfig {
226            y_tick_labels: vec!["100,000".into(), "1,000,000".into()],
227            ..Default::default()
228        };
229        let m = calculate_margins(&config);
230        // Left margin should be max label width + 15px buffer
231        let expected_max_width = approximate_text_width("1,000,000");
232        let expected_left = expected_max_width + 15.0;
233        assert!((m.left - expected_left).abs() < f64::EPSILON,
234            "Expected left margin ~{}, got {}", expected_left, m.left);
235    }
236
237    #[test]
238    fn margins_capped() {
239        let config = MarginConfig {
240            y_tick_labels: vec!["A".repeat(100)], // very wide label
241            max_left_margin: 250.0,
242            ..Default::default()
243        };
244        let m = calculate_margins(&config);
245        assert!(m.left <= 250.0, "Left margin {} exceeds cap of 250", m.left);
246    }
247}