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    pub has_legend: bool,
39    pub y_tick_labels: Vec<String>,
40    pub right_tick_labels: Vec<String>,
41    pub x_label_strategy_margin: f64,
42    pub max_left_margin: f64,
43    pub max_right_margin: f64,
44    /// Total SVG height — used to scale bottom margin for small charts.
45    pub chart_height: f64,
46}
47
48impl Default for MarginConfig {
49    fn default() -> Self {
50        Self {
51            has_title: false,
52            has_x_axis_label: false,
53            has_y_axis_label: false,
54            has_right_axis: false,
55            has_legend: false,
56            y_tick_labels: Vec::new(),
57            right_tick_labels: Vec::new(),
58            x_label_strategy_margin: 0.0,
59            max_left_margin: 250.0,
60            max_right_margin: 250.0,
61            chart_height: 400.0,
62        }
63    }
64}
65
66/// Calculate chart margins based on configuration.
67///
68/// Algorithm (matches JS d3CartesianChart.js):
69/// - Top: 30px base + 25px if title present
70/// - Left: max(y-axis label widths) + 15px buffer, capped at max_left_margin
71///   + 20px if y-axis label present
72/// - Right: 20px base, or max(right-axis label widths) + 24px if right axis present,
73///   capped at max_right_margin
74/// - Bottom: 40px base + x_label_strategy_margin (rotation) + 20px if x-axis label
75///   + 30px if legend present
76pub fn calculate_margins(config: &MarginConfig) -> Margins {
77    use super::labels::approximate_text_width;
78
79    // Top margin — matches JS d3CartesianChart.js marginTop=20 (title is rendered as HTML outside SVG)
80    let top = 20.0;
81
82    // Left margin: based on Y-axis tick label widths
83    let max_y_label_width = config.y_tick_labels.iter()
84        .map(|l| approximate_text_width(l))
85        .fold(0.0_f64, f64::max);
86    let left_base = if max_y_label_width > 0.0 {
87        max_y_label_width + 15.0
88    } else {
89        70.0 // matches JS d3CartesianChart.js default marginLeft
90    };
91    let left = (left_base + if config.has_y_axis_label { 28.0 } else { 0.0 })
92        .min(config.max_left_margin);
93
94    // Right margin
95    let right = if config.has_right_axis {
96        let max_right_width = config.right_tick_labels.iter()
97            .map(|l| approximate_text_width(l))
98            .fold(0.0_f64, f64::max);
99        // 24px for tick+gap, +20px if axis title label present
100        let label_space = if config.has_right_axis { 20.0 } else { 0.0 };
101        (max_right_width + 24.0 + label_space)
102            .min(config.max_right_margin)
103    } else {
104        30.0 // matches JS d3CartesianChart.js default marginRight
105    };
106
107    // Bottom margin: 40px base covers tick marks (+5px) and horizontal label text (+18px)
108    // with some padding.  For small charts (< 300px tall), scale proportionally to
109    // avoid the base consuming too much of the chart height.
110    // The rotation margin from x_label_strategy_margin adds the vertical descent of
111    // rotated labels beyond the horizontal baseline.
112    let base_bottom = if config.chart_height < 300.0 {
113        // Scale: at 150px tall => ~25px base; at 300px => 40px base.
114        // This keeps horizontal labels visible while leaving room for the plot.
115        (config.chart_height * 0.16).clamp(20.0, 40.0)
116    } else {
117        40.0
118    };
119    let bottom = base_bottom
120        + config.x_label_strategy_margin
121        + if config.has_x_axis_label { 20.0 } else { 0.0 }
122        + if config.has_legend { 30.0 } else { 0.0 };
123
124    Margins { top, right, bottom, left }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn default_margins() {
133        let m = Margins::default();
134        assert_eq!(m.top, 20.0);
135        assert_eq!(m.right, 30.0);
136        assert_eq!(m.bottom, 40.0);
137        assert_eq!(m.left, 70.0);
138    }
139
140    #[test]
141    fn margins_inner_dimensions() {
142        let m = Margins::new(10.0, 20.0, 30.0, 40.0);
143        assert!((m.inner_width(800.0) - 740.0).abs() < f64::EPSILON);
144        assert!((m.inner_height(600.0) - 560.0).abs() < f64::EPSILON);
145    }
146
147    #[test]
148    fn margins_inner_dimensions_clamp_to_zero() {
149        let m = Margins::new(300.0, 300.0, 300.0, 300.0);
150        assert_eq!(m.inner_width(100.0), 0.0);
151        assert_eq!(m.inner_height(100.0), 0.0);
152    }
153
154    #[test]
155    fn margins_with_title() {
156        let config = MarginConfig {
157            has_title: true,
158            ..Default::default()
159        };
160        let m = calculate_margins(&config);
161        assert_eq!(m.top, 20.0); // title is rendered as HTML outside SVG — no extra margin needed
162    }
163
164    #[test]
165    fn margins_without_title() {
166        let config = MarginConfig::default();
167        let m = calculate_margins(&config);
168        assert_eq!(m.top, 20.0);
169    }
170
171    #[test]
172    fn margins_with_legend() {
173        let config = MarginConfig {
174            has_legend: true,
175            ..Default::default()
176        };
177        let m = calculate_margins(&config);
178        assert_eq!(m.bottom, 70.0); // 40 + 30
179    }
180
181    #[test]
182    fn margins_with_y_labels() {
183        use crate::layout::labels::approximate_text_width;
184        let config = MarginConfig {
185            y_tick_labels: vec!["100,000".into(), "1,000,000".into()],
186            ..Default::default()
187        };
188        let m = calculate_margins(&config);
189        // Left margin should be max label width + 15px buffer
190        let expected_max_width = approximate_text_width("1,000,000");
191        let expected_left = expected_max_width + 15.0;
192        assert!((m.left - expected_left).abs() < f64::EPSILON,
193            "Expected left margin ~{}, got {}", expected_left, m.left);
194    }
195
196    #[test]
197    fn margins_capped() {
198        let config = MarginConfig {
199            y_tick_labels: vec!["A".repeat(100)], // very wide label
200            max_left_margin: 250.0,
201            ..Default::default()
202        };
203        let m = calculate_margins(&config);
204        assert!(m.left <= 250.0, "Left margin {} exceeds cap of 250", m.left);
205    }
206}