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    use super::*;
160
161    #[test]
162    fn default_margins() {
163        let m = Margins::default();
164        assert_eq!(m.top, 20.0);
165        assert_eq!(m.right, 30.0);
166        assert_eq!(m.bottom, 40.0);
167        assert_eq!(m.left, 70.0);
168    }
169
170    #[test]
171    fn margins_inner_dimensions() {
172        let m = Margins::new(10.0, 20.0, 30.0, 40.0);
173        assert!((m.inner_width(800.0) - 740.0).abs() < f64::EPSILON);
174        assert!((m.inner_height(600.0) - 560.0).abs() < f64::EPSILON);
175    }
176
177    #[test]
178    fn margins_inner_dimensions_clamp_to_zero() {
179        let m = Margins::new(300.0, 300.0, 300.0, 300.0);
180        assert_eq!(m.inner_width(100.0), 0.0);
181        assert_eq!(m.inner_height(100.0), 0.0);
182    }
183
184    #[test]
185    fn margins_with_title() {
186        let config = MarginConfig {
187            has_title: true,
188            ..Default::default()
189        };
190        let m = calculate_margins(&config);
191        assert_eq!(m.top, 20.0); // title is rendered as HTML outside SVG — no extra margin needed
192    }
193
194    #[test]
195    fn margins_without_title() {
196        let config = MarginConfig::default();
197        let m = calculate_margins(&config);
198        assert_eq!(m.top, 20.0);
199    }
200
201    #[test]
202    fn margins_with_single_row_legend() {
203        let config = MarginConfig {
204            legend_height: 20.0, // single row
205            ..Default::default()
206        };
207        let m = calculate_margins(&config);
208        assert_eq!(m.bottom, 68.0); // 40 + 20 + 8
209    }
210
211    #[test]
212    fn margins_with_multi_row_legend() {
213        let config = MarginConfig {
214            legend_height: 60.0, // 3 rows × 20px
215            ..Default::default()
216        };
217        let m = calculate_margins(&config);
218        assert_eq!(m.bottom, 108.0); // 40 + 60 + 8
219    }
220
221    #[test]
222    fn margins_with_y_labels() {
223        use crate::layout::labels::approximate_text_width;
224        let config = MarginConfig {
225            y_tick_labels: vec!["100,000".into(), "1,000,000".into()],
226            ..Default::default()
227        };
228        let m = calculate_margins(&config);
229        // Left margin should be max label width + 15px buffer
230        let expected_max_width = approximate_text_width("1,000,000");
231        let expected_left = expected_max_width + 15.0;
232        assert!((m.left - expected_left).abs() < f64::EPSILON,
233            "Expected left margin ~{}, got {}", expected_left, m.left);
234    }
235
236    #[test]
237    fn margins_capped() {
238        let config = MarginConfig {
239            y_tick_labels: vec!["A".repeat(100)], // very wide label
240            max_left_margin: 250.0,
241            ..Default::default()
242        };
243        let m = calculate_margins(&config);
244        assert!(m.left <= 250.0, "Left margin {} exceeds cap of 250", m.left);
245    }
246}