Skip to main content

chartml_core/layout/
axes.rs

1use crate::format::NumberFormatter;
2
3/// Position of the axis relative to the chart area.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum AxisPosition {
6    Bottom, // X-axis
7    Left,   // Primary Y-axis
8    Right,  // Secondary Y-axis
9    Top,    // Rare, but supported
10}
11
12/// A single tick mark with its position and label.
13#[derive(Debug, Clone)]
14pub struct TickMark {
15    pub position: f64,    // Pixel position along the axis
16    pub value: f64,       // The actual data value
17    pub label: String,    // Formatted label text
18}
19
20/// A single tick mark for band/category axes.
21#[derive(Debug, Clone)]
22pub struct CategoryTickMark {
23    pub position: f64,    // Pixel position (center of band)
24    pub label: String,    // Category label text
25    pub bandwidth: f64,   // Width of the band
26}
27
28/// Calculate the optimal number of ticks based on available axis length in pixels.
29/// Formula: floor(axis_length / 50), clamped to 3..=10
30/// For very small charts (< ~150px), allows as few as 3 ticks to avoid crowding.
31pub fn adaptive_tick_count(axis_length_px: f64) -> usize {
32    ((axis_length_px / 50.0).floor() as usize).clamp(3, 10)
33}
34
35/// Generates tick positions and formatted labels for a continuous axis.
36pub struct AxisLayout {
37    position: AxisPosition,
38    tick_count: usize,
39    formatter: Option<NumberFormatter>,
40}
41
42impl AxisLayout {
43    pub fn new(position: AxisPosition) -> Self {
44        Self {
45            position,
46            tick_count: 5, // reasonable default
47            formatter: None,
48        }
49    }
50
51    pub fn bottom() -> Self { Self::new(AxisPosition::Bottom) }
52    pub fn left() -> Self { Self::new(AxisPosition::Left) }
53    pub fn right() -> Self { Self::new(AxisPosition::Right) }
54
55    pub fn tick_count(mut self, count: usize) -> Self {
56        self.tick_count = count;
57        self
58    }
59
60    pub fn formatter(mut self, fmt: NumberFormatter) -> Self {
61        self.formatter = Some(fmt);
62        self
63    }
64
65    /// Generate tick marks for a continuous scale.
66    /// Takes the scale's domain and range, generates ticks, maps them to positions.
67    pub fn generate_continuous_ticks(
68        &self,
69        domain: (f64, f64),
70        range: (f64, f64),
71    ) -> Vec<TickMark> {
72        use crate::scales::ScaleLinear;
73        let scale = ScaleLinear::new(domain, range);
74        let tick_values = scale.ticks(self.tick_count);
75
76        tick_values.iter().map(|&value| {
77            let position = scale.map(value);
78            let label = match &self.formatter {
79                Some(fmt) => fmt.format(value),
80                None => default_format(value),
81            };
82            TickMark { position, value, label }
83        }).collect()
84    }
85
86    /// Generate tick marks for a band/category scale.
87    pub fn generate_band_ticks(
88        &self,
89        labels: &[String],
90        range: (f64, f64),
91    ) -> Vec<CategoryTickMark> {
92        use crate::scales::ScaleBand;
93        let scale = ScaleBand::new(labels.to_vec(), range);
94        let bandwidth = scale.bandwidth();
95
96        labels.iter().map(|label| {
97            let position = scale.map(label).unwrap_or(0.0) + bandwidth / 2.0; // center of band
98            CategoryTickMark {
99                position,
100                label: label.clone(),
101                bandwidth,
102            }
103        }).collect()
104    }
105
106    pub fn position(&self) -> AxisPosition { self.position }
107}
108
109/// Default number format: no trailing zeros, reasonable precision.
110fn default_format(value: f64) -> String {
111    if value == value.floor() && value.abs() < 1e15 {
112        format!("{}", value as i64)
113    } else {
114        // Trim trailing zeros
115        let s = format!("{:.6}", value);
116        s.trim_end_matches('0').trim_end_matches('.').to_string()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    #![allow(clippy::unwrap_used)]
123    use super::*;
124
125    #[test]
126    fn continuous_ticks_count() {
127        let axis = AxisLayout::bottom().tick_count(5);
128        let ticks = axis.generate_continuous_ticks((0.0, 100.0), (0.0, 500.0));
129        assert!(ticks.len() >= 3 && ticks.len() <= 10,
130            "Expected 3-10 ticks, got {}", ticks.len());
131    }
132
133    #[test]
134    fn continuous_ticks_positions() {
135        let axis = AxisLayout::bottom().tick_count(5);
136        let ticks = axis.generate_continuous_ticks((0.0, 100.0), (0.0, 500.0));
137        for tick in &ticks {
138            assert!(tick.position >= 0.0 && tick.position <= 500.0,
139                "Tick position {} out of range [0, 500]", tick.position);
140        }
141    }
142
143    #[test]
144    fn continuous_ticks_with_formatter() {
145        let fmt = NumberFormatter::new("$,.0f");
146        let axis = AxisLayout::bottom().tick_count(5).formatter(fmt);
147        let ticks = axis.generate_continuous_ticks((0.0, 100.0), (0.0, 500.0));
148        for tick in &ticks {
149            assert!(tick.label.starts_with('$') || tick.label.starts_with("-$"),
150                "Expected label starting with '$', got '{}'", tick.label);
151        }
152    }
153
154    #[test]
155    fn band_ticks_centered() {
156        let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
157        let axis = AxisLayout::bottom();
158        let ticks = axis.generate_band_ticks(&labels, (0.0, 300.0));
159        for tick in &ticks {
160            // Center position should be within the range
161            assert!(tick.position >= 0.0 && tick.position <= 300.0,
162                "Band tick position {} out of range", tick.position);
163        }
164    }
165
166    #[test]
167    fn band_ticks_count_matches_labels() {
168        let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into(), "D".into()];
169        let axis = AxisLayout::bottom();
170        let ticks = axis.generate_band_ticks(&labels, (0.0, 400.0));
171        assert_eq!(ticks.len(), labels.len(),
172            "Expected {} ticks, got {}", labels.len(), ticks.len());
173    }
174
175    #[test]
176    fn default_format_integer() {
177        assert_eq!(default_format(100.0), "100");
178    }
179
180    #[test]
181    fn default_format_decimal() {
182        assert_eq!(default_format(2.71), "2.71");
183    }
184
185    #[test]
186    fn adaptive_tick_count_small() {
187        assert_eq!(adaptive_tick_count(150.0), 3); // floor(150/50)=3, minimum is now 3
188    }
189
190    #[test]
191    fn adaptive_tick_count_medium() {
192        assert_eq!(adaptive_tick_count(350.0), 7);
193    }
194
195    #[test]
196    fn adaptive_tick_count_large() {
197        assert_eq!(adaptive_tick_count(600.0), 10); // floor(600/50)=12, clamped to 10
198    }
199}