Skip to main content

chartml_core/layout/
labels.rs

1/// The strategy selected for rendering labels.
2#[derive(Debug, Clone, PartialEq)]
3pub enum LabelStrategy {
4    /// Labels displayed horizontally (no transformation needed).
5    Horizontal,
6    /// Labels rotated -45 degrees. Contains the additional bottom margin needed
7    /// and an optional skip factor for label sampling after rotation.
8    Rotated { margin: f64, skip_factor: Option<usize> },
9    /// Labels truncated to max_width with ellipsis.
10    Truncated { max_width: f64 },
11    /// Only a subset of labels shown (evenly sampled).
12    Sampled { indices: Vec<usize> },
13}
14
15/// Configuration for label strategy determination.
16pub struct LabelStrategyConfig {
17    pub min_label_spacing: f64,   // Default: 10.0 px
18    pub max_label_width: f64,     // Default: 120.0 px for truncation
19    pub max_rotation_margin: f64, // Default: 150.0 px
20    pub rotation_angle_deg: f64,  // Default: 45.0 degrees
21}
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    /// Determine the best label strategy based on available space and label measurements.
36    ///
37    /// Algorithm (cascading priority):
38    /// 1. Horizontal: if labels fit without overlap
39    /// 2. Rotated: if <= 40 labels, rotate -45 degrees (post-rotation truncation
40    ///    is applied later in generate_x_axis to guarantee no overlap)
41    /// 3. Truncated: if truncated labels fit and <= 50 labels
42    /// 4. Sampled: show an evenly-distributed subset
43    ///
44    /// Parameters:
45    /// - labels: the label strings
46    /// - available_width: total width available for the axis (chart width)
47    /// - config: strategy configuration
48    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        // Measure label widths using character approximation
61        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        // Strategy 1: Horizontal -- labels fit without overlap
66        if avg_width + config.min_label_spacing <= available_per_label {
67            return LabelStrategy::Horizontal;
68        }
69
70        // Strategy 2: Rotated -- rotate -45 degrees if not too many labels.
71        // Post-rotation truncation is handled in generate_x_axis to ensure
72        // rotated labels don't collide even when they are long.
73        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        // Strategy 3: Truncated -- if truncated labels would fit
82        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        // Strategy 4: Sampled -- show a subset
87        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
93/// Approximate width of a single character in pixels at default font size (~12px).
94fn 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
104/// Approximate text width in pixels using a character-width table.
105pub fn approximate_text_width(text: &str) -> f64 {
106    text.chars().map(char_width).sum()
107}
108
109/// After rotation, check if labels still overlap and compute skip factor.
110/// Since post-rotation truncation is applied in generate_x_axis, the skip
111/// factor only needs to engage when there are so many labels that even a
112/// minimal truncated label (~40px wide) would overlap after rotation.
113pub 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    // Minimum useful label width: ~40px (about 5 chars + ellipsis).
124    // If even this minimal rotated width doesn't fit, we need to skip.
125    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        // Compute skip so that the remaining labels have enough room
131        Some((overlap_ratio.ceil() as usize).max(2))
132    } else {
133        None
134    }
135}
136
137/// Select strategic indices for sampled label display.
138/// Always includes first and last; evenly distributes the rest.
139pub 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    // Deduplicate while preserving order
160    indices.dedup();
161    indices
162}
163
164/// Truncate a label to fit within max_width, adding ellipsis.
165pub 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        // Use <= 12 labels that don't fit horizontally to get Rotated
202        // (> 12 labels now prefer Sampled over Rotated)
203        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        // 20 labels that don't fit horizontally should be Rotated (<=40 labels)
214        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        // 18 monthly labels should be Rotated (<=40 labels)
225        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}