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 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); }
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, ..Default::default()
206 };
207 let m = calculate_margins(&config);
208 assert_eq!(m.bottom, 68.0); }
210
211 #[test]
212 fn margins_with_multi_row_legend() {
213 let config = MarginConfig {
214 legend_height: 60.0, ..Default::default()
216 };
217 let m = calculate_margins(&config);
218 assert_eq!(m.bottom, 108.0); }
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 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)], 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}