chartml_core/layout/
margins.rs1#[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 pub fn inner_width(&self, total_width: f64) -> f64 {
17 (total_width - self.left - self.right).max(0.0)
18 }
19
20 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
32pub 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 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 pub chart_height: f64,
49 pub tick_value_metrics: super::labels::TextMetrics,
54 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
79pub fn calculate_margins(config: &MarginConfig) -> Margins {
90 use super::labels::measure_text;
91
92 let top = 20.0;
94
95 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 };
105 let left = if config.has_y_axis_label {
110 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 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 (max_right_width + 24.0 + 20.0)
132 .min(config.max_right_margin)
133 } else {
134 30.0 };
136
137 let base_bottom = if config.chart_height < 300.0 {
143 (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); }
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, ..Default::default()
207 };
208 let m = calculate_margins(&config);
209 assert_eq!(m.bottom, 68.0); }
211
212 #[test]
213 fn margins_with_multi_row_legend() {
214 let config = MarginConfig {
215 legend_height: 60.0, ..Default::default()
217 };
218 let m = calculate_margins(&config);
219 assert_eq!(m.bottom, 108.0); }
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 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)], 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}