chartml_core/layout/
axes.rs1use crate::format::NumberFormatter;
2
3#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum AxisPosition {
6 Bottom, Left, Right, Top, }
11
12#[derive(Debug, Clone)]
14pub struct TickMark {
15 pub position: f64, pub value: f64, pub label: String, }
19
20#[derive(Debug, Clone)]
22pub struct CategoryTickMark {
23 pub position: f64, pub label: String, pub bandwidth: f64, }
27
28pub fn adaptive_tick_count(axis_length_px: f64) -> usize {
32 ((axis_length_px / 50.0).floor() as usize).clamp(3, 10)
33}
34
35pub 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, 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 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 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; CategoryTickMark {
99 position,
100 label: label.clone(),
101 bandwidth,
102 }
103 }).collect()
104 }
105
106 pub fn position(&self) -> AxisPosition { self.position }
107}
108
109fn default_format(value: f64) -> String {
111 if value == value.floor() && value.abs() < 1e15 {
112 format!("{}", value as i64)
113 } else {
114 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 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); }
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); }
198}