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 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 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
66pub fn calculate_margins(config: &MarginConfig) -> Margins {
77 use super::labels::approximate_text_width;
78
79 let top = 20.0;
81
82 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 };
91 let left = (left_base + if config.has_y_axis_label { 28.0 } else { 0.0 })
92 .min(config.max_left_margin);
93
94 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 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 };
106
107 let base_bottom = if config.chart_height < 300.0 {
113 (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); }
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); }
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 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)], 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}