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    use super::*;
123
124    #[test]
125    fn continuous_ticks_count() {
126        let axis = AxisLayout::bottom().tick_count(5);
127        let ticks = axis.generate_continuous_ticks((0.0, 100.0), (0.0, 500.0));
128        assert!(ticks.len() >= 3 && ticks.len() <= 10,
129            "Expected 3-10 ticks, got {}", ticks.len());
130    }
131
132    #[test]
133    fn continuous_ticks_positions() {
134        let axis = AxisLayout::bottom().tick_count(5);
135        let ticks = axis.generate_continuous_ticks((0.0, 100.0), (0.0, 500.0));
136        for tick in &ticks {
137            assert!(tick.position >= 0.0 && tick.position <= 500.0,
138                "Tick position {} out of range [0, 500]", tick.position);
139        }
140    }
141
142    #[test]
143    fn continuous_ticks_with_formatter() {
144        let fmt = NumberFormatter::new("$,.0f");
145        let axis = AxisLayout::bottom().tick_count(5).formatter(fmt);
146        let ticks = axis.generate_continuous_ticks((0.0, 100.0), (0.0, 500.0));
147        for tick in &ticks {
148            assert!(tick.label.starts_with('$') || tick.label.starts_with("-$"),
149                "Expected label starting with '$', got '{}'", tick.label);
150        }
151    }
152
153    #[test]
154    fn band_ticks_centered() {
155        let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
156        let axis = AxisLayout::bottom();
157        let ticks = axis.generate_band_ticks(&labels, (0.0, 300.0));
158        for tick in &ticks {
159            // Center position should be within the range
160            assert!(tick.position >= 0.0 && tick.position <= 300.0,
161                "Band tick position {} out of range", tick.position);
162        }
163    }
164
165    #[test]
166    fn band_ticks_count_matches_labels() {
167        let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into(), "D".into()];
168        let axis = AxisLayout::bottom();
169        let ticks = axis.generate_band_ticks(&labels, (0.0, 400.0));
170        assert_eq!(ticks.len(), labels.len(),
171            "Expected {} ticks, got {}", labels.len(), ticks.len());
172    }
173
174    #[test]
175    fn default_format_integer() {
176        assert_eq!(default_format(100.0), "100");
177    }
178
179    #[test]
180    fn default_format_decimal() {
181        assert_eq!(default_format(3.14), "3.14");
182    }
183
184    #[test]
185    fn adaptive_tick_count_small() {
186        assert_eq!(adaptive_tick_count(150.0), 3); // floor(150/50)=3, minimum is now 3
187    }
188
189    #[test]
190    fn adaptive_tick_count_medium() {
191        assert_eq!(adaptive_tick_count(350.0), 7);
192    }
193
194    #[test]
195    fn adaptive_tick_count_large() {
196        assert_eq!(adaptive_tick_count(600.0), 10); // floor(600/50)=12, clamped to 10
197    }
198}