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 #![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 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); }
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); }
199}