Skip to main content

orbital_charts/engine/
scales.rs

1//! Pure scale builders for chart geometry.
2
3use chrono::NaiveDate;
4
5/// Band scale mapping categorical domain to a numeric range.
6#[derive(Clone, Debug, PartialEq)]
7pub struct BandScale {
8    domain: Vec<String>,
9    range_start: f64,
10    range_end: f64,
11    step: f64,
12    bandwidth: f64,
13    padding_inner: f64,
14    padding_outer: f64,
15}
16
17impl BandScale {
18    /// Create a band scale with inner and outer padding (0.0–1.0).
19    ///
20    /// Outer padding insets the first and last bands from the range edges so bars
21    /// do not sit flush against the y-axis line or the plot boundary.
22    pub fn new(domain: Vec<String>, range: (f64, f64), padding_inner: f64) -> Self {
23        Self::with_padding(domain, range, padding_inner, padding_inner)
24    }
25
26    /// Create a band scale with independent inner and outer padding ratios.
27    pub fn with_padding(
28        domain: Vec<String>,
29        range: (f64, f64),
30        padding_inner: f64,
31        padding_outer: f64,
32    ) -> Self {
33        let n = domain.len().max(1) as f64;
34        let range_start = range.0.min(range.1);
35        let range_end = range.0.max(range.1);
36        let padding_inner = padding_inner.clamp(0.0, 1.0);
37        let padding_outer = padding_outer.clamp(0.0, 1.0);
38        let inner_gaps = padding_inner * (n - 1.0).max(0.0);
39        let step = (range_end - range_start) / (n + inner_gaps + padding_outer * 2.0).max(1.0);
40        let bandwidth = step * (1.0 - padding_inner);
41
42        Self {
43            domain,
44            range_start,
45            range_end,
46            step,
47            bandwidth,
48            padding_inner,
49            padding_outer,
50        }
51    }
52
53    /// Band width in range units.
54    pub fn bandwidth(&self) -> f64 {
55        self.bandwidth
56    }
57
58    /// Step between band starts.
59    pub fn step(&self) -> f64 {
60        self.step
61    }
62
63    /// Map a category to the center of its band.
64    pub fn scale(&self, category: &str) -> Option<f64> {
65        let index = self.domain.iter().position(|c| c == category)?;
66        self.scale_by_index(index)
67    }
68
69    /// Map a domain index to the center of its band.
70    pub fn scale_by_index(&self, index: usize) -> Option<f64> {
71        if index >= self.domain.len() {
72            return None;
73        }
74        let band_start =
75            self.range_start + self.padding_outer * self.step + self.step * index as f64;
76        Some(band_start + self.bandwidth / 2.0)
77    }
78
79    /// Domain categories in band order.
80    pub fn domain(&self) -> &[String] {
81        &self.domain
82    }
83
84    /// Map a range coordinate to the nearest domain index.
85    pub fn index_at(&self, position: f64) -> Option<usize> {
86        if self.domain.is_empty() {
87            return None;
88        }
89        let pos = position.clamp(self.range_start, self.range_end);
90        let relative = pos - self.range_start - self.padding_outer * self.step;
91        if relative < 0.0 {
92            return None;
93        }
94        let idx = (relative / self.step).floor() as usize;
95        if idx >= self.domain.len() {
96            None
97        } else {
98            Some(idx)
99        }
100    }
101
102    /// Fraction (0–1) of full domain for a range position (for zoom pointer anchor).
103    pub fn position_to_fraction(&self, position: f64) -> Option<f64> {
104        let idx = self.index_at(position)?;
105        Some((idx as f64 + 0.5) / self.domain.len() as f64)
106    }
107
108    /// Band rectangle `[x, width]` for a domain index in range coordinates.
109    pub fn band_rect(&self, index: usize) -> Option<(f64, f64)> {
110        if index >= self.domain.len() {
111            return None;
112        }
113        let x = self.range_start + self.padding_outer * self.step + self.step * index as f64;
114        Some((x, self.bandwidth))
115    }
116}
117
118/// Linear scale mapping numeric domain to range.
119#[derive(Clone, Debug, PartialEq)]
120pub struct LinearScale {
121    domain_min: f64,
122    domain_max: f64,
123    range_start: f64,
124    range_end: f64,
125}
126
127impl LinearScale {
128    /// Create a linear scale.
129    pub fn new(domain: (f64, f64), range: (f64, f64)) -> Self {
130        Self {
131            domain_min: domain.0,
132            domain_max: domain.1,
133            range_start: range.0,
134            range_end: range.1,
135        }
136    }
137
138    /// Map a domain value to range coordinates.
139    pub fn scale(&self, value: f64) -> f64 {
140        let domain_span = self.domain_max - self.domain_min;
141        if domain_span == 0.0 {
142            return (self.range_start + self.range_end) / 2.0;
143        }
144        let t = (value - self.domain_min) / domain_span;
145        self.range_start + t * (self.range_end - self.range_start)
146    }
147
148    /// Map a range coordinate back to a domain value.
149    pub fn invert(&self, pixel: f64) -> f64 {
150        let range_span = self.range_end - self.range_start;
151        if range_span == 0.0 {
152            return (self.domain_min + self.domain_max) / 2.0;
153        }
154        let t = (pixel - self.range_start) / range_span;
155        self.domain_min + t * (self.domain_max - self.domain_min)
156    }
157}
158
159/// Logarithmic (base-10) scale.
160#[derive(Clone, Debug, PartialEq)]
161pub struct LogScale {
162    inner: LinearScale,
163}
164
165impl LogScale {
166    /// Create a log10 scale. Domain values must be positive.
167    pub fn new(domain: (f64, f64), range: (f64, f64)) -> Option<Self> {
168        if domain.0 <= 0.0 || domain.1 <= 0.0 {
169            return None;
170        }
171        Some(Self {
172            inner: LinearScale::new((domain.0.log10(), domain.1.log10()), range),
173        })
174    }
175
176    /// Map a positive domain value to range coordinates.
177    pub fn scale(&self, value: f64) -> Option<f64> {
178        if value <= 0.0 {
179            return None;
180        }
181        Some(self.inner.scale(value.log10()))
182    }
183}
184
185/// Ordinal time scale mapping dates to range positions by index.
186#[derive(Clone, Debug, PartialEq)]
187pub struct TimeScale {
188    domain: Vec<NaiveDate>,
189    inner: LinearScale,
190}
191
192impl TimeScale {
193    /// Create a time scale using ordinal date positions.
194    pub fn new(dates: Vec<NaiveDate>, range: (f64, f64)) -> Self {
195        let max_index = dates.len().saturating_sub(1).max(1) as f64;
196        Self {
197            domain: dates,
198            inner: LinearScale::new((0.0, max_index), range),
199        }
200    }
201
202    /// Map a date to range coordinates by ordinal index.
203    pub fn scale(&self, date: NaiveDate) -> Option<f64> {
204        let index = self.domain.iter().position(|d| *d == date)?;
205        Some(self.inner.scale(index as f64))
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use chrono::NaiveDate;
213
214    #[test]
215    fn band_scale_maps_categories() {
216        let scale = BandScale::new(vec!["A".into(), "B".into(), "C".into()], (0.0, 300.0), 0.1);
217        assert!(scale.bandwidth() > 0.0);
218        let b = scale.scale("B").unwrap();
219        assert!((b - 136.76470588235292).abs() < 1e-9);
220        assert!(scale.scale("missing").is_none());
221    }
222
223    #[test]
224    fn band_scale_offsets_first_band_from_range_start() {
225        let scale = BandScale::new(vec!["A".into(), "B".into()], (0.0, 200.0), 0.1);
226        let center = scale.scale("A").unwrap();
227        let band_start = center - scale.bandwidth() / 2.0;
228        assert!(band_start > 0.0);
229    }
230
231    #[test]
232    fn linear_scale_interpolates() {
233        let scale = LinearScale::new((0.0, 100.0), (0.0, 200.0));
234        assert_eq!(scale.scale(50.0), 100.0);
235        assert_eq!(scale.scale(0.0), 0.0);
236    }
237
238    #[test]
239    fn log_scale_requires_positive_domain() {
240        assert!(LogScale::new((0.0, 100.0), (0.0, 100.0)).is_none());
241        let scale = LogScale::new((1.0, 1000.0), (0.0, 100.0)).unwrap();
242        assert!((scale.scale(10.0).unwrap() - 33.33333333333333).abs() < f64::EPSILON);
243        assert!(scale.scale(0.0).is_none());
244    }
245
246    #[test]
247    fn time_scale_maps_by_index() {
248        let dates = vec![
249            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
250            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
251            NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
252        ];
253        let scale = TimeScale::new(dates.clone(), (0.0, 100.0));
254        assert_eq!(scale.scale(dates[1]).unwrap(), 50.0);
255    }
256}