Skip to main content

chartml_core/scales/
linear.rs

1use super::{ContinuousScale, tick_step, round_to_precision};
2
3/// Maps a continuous domain to a continuous range via linear interpolation.
4/// Equivalent to D3's `scaleLinear()`.
5pub struct ScaleLinear {
6    domain: (f64, f64),
7    range: (f64, f64),
8}
9
10impl ScaleLinear {
11    /// Create a new linear scale with the given domain and range.
12    pub fn new(domain: (f64, f64), range: (f64, f64)) -> Self {
13        Self { domain, range }
14    }
15
16    /// Map a domain value to a range value using linear interpolation.
17    pub fn map(&self, value: f64) -> f64 {
18        let (d0, d1) = self.domain;
19        let (r0, r1) = self.range;
20        let domain_span = d1 - d0;
21        if domain_span == 0.0 {
22            // When domain is a single point, return the midpoint of the range.
23            return (r0 + r1) / 2.0;
24        }
25        r0 + (value - d0) / domain_span * (r1 - r0)
26    }
27
28    /// Inverse mapping: range value back to domain value.
29    pub fn invert(&self, value: f64) -> f64 {
30        let (d0, d1) = self.domain;
31        let (r0, r1) = self.range;
32        let range_span = r1 - r0;
33        if range_span == 0.0 {
34            return (d0 + d1) / 2.0;
35        }
36        d0 + (value - r0) / range_span * (d1 - d0)
37    }
38
39    /// Generate approximately `count` nice tick values using the D3 "nice numbers" algorithm.
40    /// Ticks are returned in the same order as the domain (descending if domain is reversed).
41    pub fn ticks(&self, count: usize) -> Vec<f64> {
42        if count == 0 {
43            return vec![];
44        }
45        let (d0, d1) = self.domain;
46        let reversed = d0 > d1;
47        let min = d0.min(d1);
48        let max = d0.max(d1);
49        if min == max {
50            return vec![min];
51        }
52
53        let step = tick_step(min, max, count);
54        if step == 0.0 || !step.is_finite() {
55            return vec![];
56        }
57
58        let mut ticks = Vec::new();
59        let start = (min / step).ceil();
60        let stop = (max / step).floor();
61
62        let mut i = start;
63        while i <= stop {
64            let tick = i * step;
65            let tick = round_to_precision(tick, step);
66            ticks.push(tick);
67            i += 1.0;
68        }
69
70        if reversed {
71            ticks.reverse();
72        }
73
74        ticks
75    }
76
77    /// Extend the domain to nice round numbers (like D3's `.nice()`).
78    /// Uses D3's iterative algorithm (up to 10 passes) so the domain expands
79    /// until the tick step stabilises — matching d3-scale's linearish.nice().
80    /// Preserves domain direction (reversed domains stay reversed).
81    pub fn nice(self, count: usize) -> Self {
82        if count == 0 {
83            return self;
84        }
85        let (d0, d1) = self.domain;
86        let reversed = d0 > d1;
87        let mut start = d0.min(d1);
88        let mut stop  = d0.max(d1);
89        if start == stop {
90            return self;
91        }
92
93        let mut prestep = f64::NAN;
94        let mut max_iter = 10i32;
95        while max_iter > 0 {
96            max_iter -= 1;
97            let step = tick_step(start, stop, count);
98            if step == 0.0 || !step.is_finite() {
99                break;
100            }
101            if step == prestep {
102                // Stable — done.
103                break;
104            }
105            // D3 also handles step < 0 (sub-unit steps use reciprocal encoding),
106            // but tick_step always returns positive for our data ranges.
107            start = (start / step).floor() * step;
108            stop  = (stop  / step).ceil()  * step;
109            prestep = step;
110        }
111
112        let domain = if reversed {
113            (stop, start)
114        } else {
115            (start, stop)
116        };
117
118        Self {
119            domain,
120            range: self.range,
121        }
122    }
123
124    /// Get the domain extent.
125    pub fn domain(&self) -> (f64, f64) {
126        self.domain
127    }
128
129    /// Get the range extent.
130    pub fn range(&self) -> (f64, f64) {
131        self.range
132    }
133}
134
135impl ContinuousScale for ScaleLinear {
136    fn map(&self, value: f64) -> f64 {
137        ScaleLinear::map(self, value)
138    }
139
140    fn domain(&self) -> (f64, f64) {
141        ScaleLinear::domain(self)
142    }
143
144    fn range(&self) -> (f64, f64) {
145        ScaleLinear::range(self)
146    }
147
148    fn ticks(&self, count: usize) -> Vec<f64> {
149        ScaleLinear::ticks(self, count)
150    }
151
152    fn clamp(&self, value: f64) -> f64 {
153        let (d0, d1) = self.domain;
154        let min = d0.min(d1);
155        let max = d0.max(d1);
156        value.clamp(min, max)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn linear_scale_maps_midpoint() {
166        let scale = ScaleLinear::new((0.0, 100.0), (0.0, 500.0));
167        assert!((scale.map(50.0) - 250.0).abs() < 1e-10);
168    }
169
170    #[test]
171    fn linear_scale_maps_endpoints() {
172        let scale = ScaleLinear::new((0.0, 100.0), (0.0, 500.0));
173        assert!((scale.map(0.0) - 0.0).abs() < 1e-10);
174        assert!((scale.map(100.0) - 500.0).abs() < 1e-10);
175    }
176
177    #[test]
178    fn linear_scale_inverts() {
179        let scale = ScaleLinear::new((0.0, 100.0), (0.0, 500.0));
180        assert!((scale.invert(250.0) - 50.0).abs() < 1e-10);
181    }
182
183    #[test]
184    fn linear_scale_reversed_range() {
185        let scale = ScaleLinear::new((0.0, 100.0), (500.0, 0.0));
186        assert!((scale.map(0.0) - 500.0).abs() < 1e-10);
187        assert!((scale.map(100.0) - 0.0).abs() < 1e-10);
188    }
189
190    #[test]
191    fn linear_scale_ticks() {
192        let scale = ScaleLinear::new((0.0, 100.0), (0.0, 500.0));
193        let ticks = scale.ticks(5);
194        let expected = vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0];
195        assert_eq!(ticks.len(), expected.len(), "tick count mismatch: got {:?}", ticks);
196        for (a, b) in ticks.iter().zip(expected.iter()) {
197            assert!((a - b).abs() < 1e-10, "tick mismatch: {} vs {}", a, b);
198        }
199    }
200
201    #[test]
202    fn linear_scale_ticks_non_round() {
203        let scale = ScaleLinear::new((0.0, 1.0), (0.0, 500.0));
204        let ticks = scale.ticks(5);
205        let expected = vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0];
206        assert_eq!(ticks.len(), expected.len(), "tick count mismatch: got {:?}", ticks);
207        for (a, b) in ticks.iter().zip(expected.iter()) {
208            assert!((a - b).abs() < 1e-10, "tick mismatch: {} vs {}", a, b);
209        }
210    }
211
212    #[test]
213    fn linear_scale_nice() {
214        let scale = ScaleLinear::new((0.5, 9.7), (0.0, 500.0)).nice(10);
215        let (d0, d1) = scale.domain();
216        assert!((d0 - 0.0).abs() < 1e-10, "nice min should be 0, got {}", d0);
217        assert!((d1 - 10.0).abs() < 1e-10, "nice max should be 10, got {}", d1);
218    }
219
220    #[test]
221    fn linear_scale_single_value_domain() {
222        let scale = ScaleLinear::new((5.0, 5.0), (0.0, 500.0));
223        assert!((scale.map(5.0) - 250.0).abs() < 1e-10);
224    }
225
226    #[test]
227    fn linear_scale_negative_domain() {
228        let scale = ScaleLinear::new((-100.0, 100.0), (0.0, 1000.0));
229        assert!((scale.map(0.0) - 500.0).abs() < 1e-10);
230    }
231
232    #[test]
233    fn linear_scale_reversed_domain_ticks() {
234        let scale = ScaleLinear::new((100.0, 0.0), (0.0, 500.0));
235        let ticks = scale.ticks(5);
236        // Ticks should be in descending order for reversed domain
237        assert!(ticks[0] > ticks[ticks.len() - 1]);
238        assert!((ticks[0] - 100.0).abs() < 1e-10);
239        assert!((ticks[ticks.len() - 1] - 0.0).abs() < 1e-10);
240    }
241
242    #[test]
243    fn linear_scale_reversed_domain_nice() {
244        let scale = ScaleLinear::new((9.7, 0.5), (0.0, 500.0)).nice(10);
245        let (d0, d1) = scale.domain();
246        // nice() should preserve reversed direction: d0 > d1
247        assert!(d0 > d1, "reversed domain should stay reversed: ({}, {})", d0, d1);
248        assert!((d0 - 10.0).abs() < 1e-10);
249        assert!((d1 - 0.0).abs() < 1e-10);
250    }
251
252    #[test]
253    fn linear_scale_invert_reversed_range() {
254        let scale = ScaleLinear::new((0.0, 100.0), (500.0, 0.0));
255        assert!((scale.invert(250.0) - 50.0).abs() < 1e-10);
256        assert!((scale.invert(500.0) - 0.0).abs() < 1e-10);
257    }
258
259    #[test]
260    fn linear_scale_ticks_zero() {
261        let scale = ScaleLinear::new((0.0, 100.0), (0.0, 500.0));
262        assert!(scale.ticks(0).is_empty());
263    }
264
265    #[test]
266    fn linear_scale_ticks_one() {
267        let scale = ScaleLinear::new((0.0, 100.0), (0.0, 500.0));
268        let ticks = scale.ticks(1);
269        // Should produce at least one tick
270        assert!(!ticks.is_empty());
271    }
272}