1#[derive(Debug, Clone, PartialEq)]
3pub enum LabelStrategy {
4 Horizontal,
6 Rotated { margin: f64, skip_factor: Option<usize> },
9 Truncated { max_width: f64 },
11 Sampled { indices: Vec<usize> },
13}
14
15pub struct LabelStrategyConfig {
17 pub min_label_spacing: f64, pub max_label_width: f64, pub max_rotation_margin: f64, pub rotation_angle_deg: f64, }
22
23impl Default for LabelStrategyConfig {
24 fn default() -> Self {
25 Self {
26 min_label_spacing: 10.0,
27 max_label_width: 120.0,
28 max_rotation_margin: 150.0,
29 rotation_angle_deg: 45.0,
30 }
31 }
32}
33
34impl LabelStrategy {
35 pub fn determine(
49 labels: &[String],
50 available_width: f64,
51 config: &LabelStrategyConfig,
52 ) -> Self {
53 let label_count = labels.len();
54 if label_count == 0 {
55 return LabelStrategy::Horizontal;
56 }
57
58 let available_per_label = available_width / label_count as f64;
59
60 let widths: Vec<f64> = labels.iter().map(|l| approximate_text_width(l)).collect();
62 let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
63 let max_width = widths.iter().cloned().fold(0.0_f64, f64::max);
64
65 if avg_width + config.min_label_spacing <= available_per_label {
67 return LabelStrategy::Horizontal;
68 }
69
70 if label_count <= 40 {
74 let angle_rad = config.rotation_angle_deg.to_radians();
75 let required_vertical = max_width * angle_rad.sin();
76 let margin = (required_vertical.ceil() + 15.0).min(config.max_rotation_margin);
77 let skip_factor = compute_skip_factor(labels, available_width, config.rotation_angle_deg);
78 return LabelStrategy::Rotated { margin, skip_factor };
79 }
80
81 if config.max_label_width + config.min_label_spacing <= available_per_label && label_count <= 50 {
83 return LabelStrategy::Truncated { max_width: config.max_label_width };
84 }
85
86 let target_count = ((available_width / 120.0).floor() as usize).max(5);
88 let indices = strategic_indices(label_count, target_count);
89 LabelStrategy::Sampled { indices }
90 }
91}
92
93fn char_width(ch: char) -> f64 {
95 match ch {
96 'M' | 'W' | 'm' | 'w' => 9.0,
97 'i' | 'l' | 'j' | '!' | '|' | '.' | ',' | ':' | ';' | '\'' => 4.0,
98 'f' | 'r' | 't' => 5.0,
99 ' ' => 4.0,
100 _ => 7.0,
101 }
102}
103
104pub fn approximate_text_width(text: &str) -> f64 {
106 text.chars().map(char_width).sum()
107}
108
109pub fn compute_skip_factor(
114 labels: &[String],
115 available_width: f64,
116 rotation_angle_deg: f64,
117) -> Option<usize> {
118 if labels.len() <= 8 {
119 return None;
120 }
121 let available_per_label = available_width / labels.len() as f64;
122 let cos_angle = rotation_angle_deg.to_radians().cos();
123 let min_label_width = 40.0;
126 let min_rotated_width = min_label_width * cos_angle;
127 let spacing = 6.0;
128 let overlap_ratio = (min_rotated_width + spacing) / available_per_label;
129 if overlap_ratio > 1.0 {
130 Some((overlap_ratio.ceil() as usize).max(2))
132 } else {
133 None
134 }
135}
136
137pub fn strategic_indices(total: usize, target: usize) -> Vec<usize> {
140 if total == 0 {
141 return vec![];
142 }
143 if target >= total {
144 return (0..total).collect();
145 }
146 if target <= 1 {
147 return if total == 1 { vec![0] } else { vec![0, total - 1] };
148 }
149 if target == 2 {
150 return vec![0, total - 1];
151 }
152
153 let mut indices = Vec::with_capacity(target);
154 let step = (total - 1) as f64 / (target - 1) as f64;
155 for i in 0..target {
156 let idx = (i as f64 * step).round() as usize;
157 indices.push(idx.min(total - 1));
158 }
159 indices.dedup();
161 indices
162}
163
164pub fn truncate_label(label: &str, max_width: f64) -> String {
166 let full_width = approximate_text_width(label);
167 if full_width <= max_width {
168 return label.to_string();
169 }
170
171 let ellipsis_width = approximate_text_width("\u{2026}");
172 let target_width = max_width - ellipsis_width;
173
174 let mut width = 0.0;
175 let mut end_idx = 0;
176 for (i, ch) in label.char_indices() {
177 let cw = char_width(ch);
178 if width + cw > target_width {
179 break;
180 }
181 width += cw;
182 end_idx = i + ch.len_utf8();
183 }
184
185 format!("{}\u{2026}", &label[..end_idx])
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn strategy_horizontal_when_fits() {
194 let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
195 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
196 assert_eq!(strategy, LabelStrategy::Horizontal);
197 }
198
199 #[test]
200 fn strategy_rotated_when_moderate() {
201 let labels: Vec<String> = (0..10)
204 .map(|i| format!("Category {}", i))
205 .collect();
206 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
207 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
208 "Expected Rotated, got {:?}", strategy);
209 }
210
211 #[test]
212 fn strategy_rotated_when_dense_axis() {
213 let labels: Vec<String> = (0..20)
215 .map(|i| format!("Category {}", i))
216 .collect();
217 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
218 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
219 "Expected Rotated, got {:?}", strategy);
220 }
221
222 #[test]
223 fn strategy_rotated_for_monthly_labels() {
224 let labels: Vec<String> = (0..18)
226 .map(|i| format!("Jan {:02}", i + 1))
227 .collect();
228 let strategy = LabelStrategy::determine(&labels, 560.0, &LabelStrategyConfig::default());
229 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
230 "Expected Rotated, got {:?}", strategy);
231 }
232
233 #[test]
234 fn strategy_sampled_when_many() {
235 let labels: Vec<String> = (0..100)
236 .map(|i| format!("Long Category Name {}", i))
237 .collect();
238 let strategy = LabelStrategy::determine(&labels, 400.0, &LabelStrategyConfig::default());
239 assert!(matches!(strategy, LabelStrategy::Sampled { .. }),
240 "Expected Sampled, got {:?}", strategy);
241 }
242
243 #[test]
244 fn strategy_empty_labels() {
245 let labels: Vec<String> = vec![];
246 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
247 assert_eq!(strategy, LabelStrategy::Horizontal);
248 }
249
250 #[test]
251 fn strategic_indices_basic() {
252 let indices = strategic_indices(10, 5);
253 assert!(indices.contains(&0), "Should include first index");
254 assert!(indices.contains(&9), "Should include last index");
255 assert!(indices.len() <= 5, "Should have at most 5 indices");
256 }
257
258 #[test]
259 fn strategic_indices_all() {
260 let indices = strategic_indices(5, 10);
261 assert_eq!(indices, vec![0, 1, 2, 3, 4]);
262 }
263
264 #[test]
265 fn truncate_short_label() {
266 let result = truncate_label("Hi", 100.0);
267 assert_eq!(result, "Hi");
268 }
269
270 #[test]
271 fn truncate_long_label() {
272 let result = truncate_label("This is a very long label that should be truncated", 50.0);
273 assert!(result.ends_with('\u{2026}'), "Should end with ellipsis, got '{}'", result);
274 assert!(result.len() < "This is a very long label that should be truncated".len(),
275 "Should be shorter than original");
276 }
277
278 #[test]
279 fn approximate_text_width_basic() {
280 let width = approximate_text_width("Hello");
281 assert!(width > 0.0, "Width should be non-zero for non-empty string");
282 }
283}