Skip to main content

plotkit_core/
scale.rs

1//! Axis scale transformations (data space → axes space).
2
3/// A scale maps data values to the normalized [0, 1] range for axis display.
4#[derive(Debug, Clone, Default)]
5#[non_exhaustive]
6pub enum Scale {
7    /// Linear mapping from [min, max] to [0, 1].
8    #[default]
9    Linear,
10    /// Base-10 logarithmic scale. Data must be positive.
11    Log10,
12    /// Symmetric log scale: linear near zero, logarithmic beyond ±linthresh.
13    SymLog {
14        /// The range (-linthresh, linthresh) is treated linearly.
15        linthresh: f64,
16    },
17}
18
19impl Scale {
20    /// Applies the forward symlog function: sign(v) * (linthresh * (1 + log10(|v| / linthresh)))
21    /// for |v| >= linthresh, and v otherwise.
22    ///
23    /// This produces a continuous, monotonically increasing function that is
24    /// linear in [-linthresh, linthresh] and logarithmic outside that range.
25    fn symlog(v: f64, linthresh: f64) -> f64 {
26        let abs_v = v.abs();
27        if abs_v <= linthresh {
28            v
29        } else {
30            v.signum() * linthresh * (1.0 + (abs_v / linthresh).log10())
31        }
32    }
33
34    /// Applies the inverse symlog function. Inverts [`Self::symlog`].
35    fn symlog_inv(v: f64, linthresh: f64) -> f64 {
36        let abs_v = v.abs();
37        if abs_v <= linthresh {
38            v
39        } else {
40            v.signum() * linthresh * 10.0_f64.powf(abs_v / linthresh - 1.0)
41        }
42    }
43
44    /// Transforms a data value to the [0, 1] range given the axis [min, max].
45    ///
46    /// Values outside [min, max] will map outside [0, 1], which can be used for
47    /// clipping or extrapolation by the caller.
48    pub fn transform(&self, val: f64, min: f64, max: f64) -> f64 {
49        match self {
50            Scale::Linear => {
51                if (max - min).abs() < f64::EPSILON {
52                    0.5
53                } else {
54                    (val - min) / (max - min)
55                }
56            }
57            Scale::Log10 => {
58                let log_min = min.max(f64::EPSILON).log10();
59                let log_max = max.max(f64::EPSILON).log10();
60                let log_val = val.max(f64::EPSILON).log10();
61                if (log_max - log_min).abs() < f64::EPSILON {
62                    0.5
63                } else {
64                    (log_val - log_min) / (log_max - log_min)
65                }
66            }
67            Scale::SymLog { linthresh } => {
68                let s_min = Self::symlog(min, *linthresh);
69                let s_max = Self::symlog(max, *linthresh);
70                let s_val = Self::symlog(val, *linthresh);
71                if (s_max - s_min).abs() < f64::EPSILON {
72                    0.5
73                } else {
74                    (s_val - s_min) / (s_max - s_min)
75                }
76            }
77        }
78    }
79
80    /// Inverse transform: maps a normalized [0, 1] value back to data space.
81    pub fn inverse(&self, t: f64, min: f64, max: f64) -> f64 {
82        match self {
83            Scale::Linear => min + t * (max - min),
84            Scale::Log10 => {
85                let log_min = min.max(f64::EPSILON).log10();
86                let log_max = max.max(f64::EPSILON).log10();
87                10.0_f64.powf(log_min + t * (log_max - log_min))
88            }
89            Scale::SymLog { linthresh } => {
90                let s_min = Self::symlog(min, *linthresh);
91                let s_max = Self::symlog(max, *linthresh);
92                let s_val = s_min + t * (s_max - s_min);
93                Self::symlog_inv(s_val, *linthresh)
94            }
95        }
96    }
97
98    /// Returns true if this scale requires strictly positive data values.
99    pub fn requires_positive(&self) -> bool {
100        matches!(self, Scale::Log10)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    const TOL: f64 = 1e-12;
109
110    fn approx_eq(a: f64, b: f64) -> bool {
111        (a - b).abs() < TOL
112    }
113
114    // -----------------------------------------------------------------------
115    // Linear
116    // -----------------------------------------------------------------------
117
118    #[test]
119    fn linear_basic() {
120        let s = Scale::Linear;
121        assert!(approx_eq(s.transform(0.0, 0.0, 10.0), 0.0));
122        assert!(approx_eq(s.transform(5.0, 0.0, 10.0), 0.5));
123        assert!(approx_eq(s.transform(10.0, 0.0, 10.0), 1.0));
124    }
125
126    #[test]
127    fn linear_negative_range() {
128        let s = Scale::Linear;
129        assert!(approx_eq(s.transform(-5.0, -10.0, 0.0), 0.5));
130    }
131
132    #[test]
133    fn linear_degenerate_range() {
134        let s = Scale::Linear;
135        assert!(approx_eq(s.transform(5.0, 5.0, 5.0), 0.5));
136    }
137
138    #[test]
139    fn linear_inverse_roundtrip() {
140        let s = Scale::Linear;
141        let min = -3.0;
142        let max = 7.0;
143        for &val in &[-3.0, 0.0, 2.5, 7.0] {
144            let t = s.transform(val, min, max);
145            let recovered = s.inverse(t, min, max);
146            assert!(approx_eq(recovered, val), "roundtrip failed for {val}");
147        }
148    }
149
150    // -----------------------------------------------------------------------
151    // Log10
152    // -----------------------------------------------------------------------
153
154    #[test]
155    fn log10_basic() {
156        let s = Scale::Log10;
157        assert!(approx_eq(s.transform(1.0, 1.0, 1000.0), 0.0));
158        assert!(approx_eq(s.transform(1000.0, 1.0, 1000.0), 1.0));
159        // 10^1.5 ≈ 31.62 is the midpoint in log space between 1 and 1000
160        let mid = 10.0_f64.powf(1.5);
161        assert!(approx_eq(s.transform(mid, 1.0, 1000.0), 0.5));
162    }
163
164    #[test]
165    fn log10_degenerate_range() {
166        let s = Scale::Log10;
167        assert!(approx_eq(s.transform(5.0, 5.0, 5.0), 0.5));
168    }
169
170    #[test]
171    fn log10_clamps_non_positive() {
172        let s = Scale::Log10;
173        // Non-positive values should be clamped to EPSILON internally
174        let t = s.transform(-1.0, 1.0, 100.0);
175        assert!(t.is_finite());
176    }
177
178    #[test]
179    fn log10_inverse_roundtrip() {
180        let s = Scale::Log10;
181        let min = 1.0;
182        let max = 10000.0;
183        for &val in &[1.0, 10.0, 100.0, 1000.0, 10000.0] {
184            let t = s.transform(val, min, max);
185            let recovered = s.inverse(t, min, max);
186            assert!(
187                (recovered - val).abs() < 1e-6,
188                "roundtrip failed for {val}: got {recovered}"
189            );
190        }
191    }
192
193    #[test]
194    fn log10_requires_positive() {
195        assert!(Scale::Log10.requires_positive());
196        assert!(!Scale::Linear.requires_positive());
197        assert!(!Scale::SymLog { linthresh: 1.0 }.requires_positive());
198    }
199
200    // -----------------------------------------------------------------------
201    // SymLog
202    // -----------------------------------------------------------------------
203
204    #[test]
205    fn symlog_zero_maps_correctly() {
206        let s = Scale::SymLog { linthresh: 1.0 };
207        // Zero should be at the expected normalized position for a symmetric range.
208        let t = s.transform(0.0, -100.0, 100.0);
209        assert!(approx_eq(t, 0.5), "zero should map to 0.5 for symmetric range, got {t}");
210    }
211
212    #[test]
213    fn symlog_linear_region() {
214        let s = Scale::SymLog { linthresh: 10.0 };
215        // Within [-linthresh, linthresh] the transform is linear.
216        // symlog(5, 10) = 5, symlog(-5, 10) = -5
217        let min = -10.0;
218        let max = 10.0;
219        let t_neg5 = s.transform(-5.0, min, max);
220        let t_0 = s.transform(0.0, min, max);
221        let t_5 = s.transform(5.0, min, max);
222        // Should be evenly spaced in this region.
223        assert!(approx_eq(t_0, 0.5));
224        assert!(approx_eq(t_5 - t_0, t_0 - t_neg5));
225    }
226
227    #[test]
228    fn symlog_continuity_at_threshold() {
229        // The function should be continuous at ±linthresh.
230        let linthresh = 2.0;
231        let just_below = linthresh - 1e-14;
232        let at_thresh = linthresh;
233        let s_below = Scale::symlog(just_below, linthresh);
234        let s_at = Scale::symlog(at_thresh, linthresh);
235        assert!(
236            (s_at - s_below).abs() < 1e-10,
237            "discontinuity at +linthresh: {s_below} vs {s_at}"
238        );
239
240        let s_below_neg = Scale::symlog(-just_below, linthresh);
241        let s_at_neg = Scale::symlog(-at_thresh, linthresh);
242        assert!(
243            (s_at_neg - s_below_neg).abs() < 1e-10,
244            "discontinuity at -linthresh: {s_below_neg} vs {s_at_neg}"
245        );
246    }
247
248    #[test]
249    fn symlog_monotonic() {
250        let linthresh = 1.0;
251        let vals: Vec<f64> = (-50..=50).map(|i| i as f64 * 0.5).collect();
252        for w in vals.windows(2) {
253            let a = Scale::symlog(w[0], linthresh);
254            let b = Scale::symlog(w[1], linthresh);
255            assert!(
256                b >= a,
257                "symlog not monotonic: symlog({}) = {a}, symlog({}) = {b}",
258                w[0],
259                w[1]
260            );
261        }
262    }
263
264    #[test]
265    fn symlog_inverse_roundtrip() {
266        let s = Scale::SymLog { linthresh: 1.0 };
267        let min = -1000.0;
268        let max = 1000.0;
269        let test_vals = [
270            -1000.0, -100.0, -10.0, -1.0, -0.5, 0.0, 0.5, 1.0, 10.0, 100.0, 1000.0,
271        ];
272        for &val in &test_vals {
273            let t = s.transform(val, min, max);
274            let recovered = s.inverse(t, min, max);
275            assert!(
276                (recovered - val).abs() < 1e-8,
277                "symlog roundtrip failed for {val}: got {recovered} (t={t})"
278            );
279        }
280    }
281
282    #[test]
283    fn symlog_inverse_roundtrip_asymmetric() {
284        let s = Scale::SymLog { linthresh: 5.0 };
285        let min = -20.0;
286        let max = 500.0;
287        for &val in &[-20.0, -5.0, 0.0, 5.0, 50.0, 500.0] {
288            let t = s.transform(val, min, max);
289            let recovered = s.inverse(t, min, max);
290            assert!(
291                (recovered - val).abs() < 1e-8,
292                "symlog roundtrip failed for {val}: got {recovered}"
293            );
294        }
295    }
296
297    #[test]
298    fn symlog_degenerate_range() {
299        let s = Scale::SymLog { linthresh: 1.0 };
300        assert!(approx_eq(s.transform(5.0, 5.0, 5.0), 0.5));
301    }
302
303    #[test]
304    fn symlog_odd_symmetry() {
305        // symlog(-v) == -symlog(v) for all v (odd function).
306        let linthresh = 3.0;
307        for &v in &[0.0, 1.0, 3.0, 10.0, 100.0] {
308            let pos = Scale::symlog(v, linthresh);
309            let neg = Scale::symlog(-v, linthresh);
310            assert!(
311                approx_eq(neg, -pos),
312                "symlog is not odd-symmetric for v={v}: symlog({v})={pos}, symlog(-{v})={neg}"
313            );
314        }
315    }
316
317    // -----------------------------------------------------------------------
318    // Boundary / edge-case tests
319    // -----------------------------------------------------------------------
320
321    #[test]
322    fn transform_at_boundaries() {
323        for scale in &[
324            Scale::Linear,
325            Scale::Log10,
326            Scale::SymLog { linthresh: 1.0 },
327        ] {
328            let (min, max) = match scale {
329                Scale::Log10 => (1.0, 100.0),
330                _ => (-10.0, 10.0),
331            };
332            let t_min = scale.transform(min, min, max);
333            let t_max = scale.transform(max, min, max);
334            assert!(
335                approx_eq(t_min, 0.0),
336                "{scale:?}: transform(min) should be 0.0, got {t_min}"
337            );
338            assert!(
339                approx_eq(t_max, 1.0),
340                "{scale:?}: transform(max) should be 1.0, got {t_max}"
341            );
342        }
343    }
344
345    #[test]
346    fn inverse_at_boundaries() {
347        for scale in &[
348            Scale::Linear,
349            Scale::Log10,
350            Scale::SymLog { linthresh: 1.0 },
351        ] {
352            let (min, max) = match scale {
353                Scale::Log10 => (1.0, 100.0),
354                _ => (-10.0, 10.0),
355            };
356            let recovered_min = scale.inverse(0.0, min, max);
357            let recovered_max = scale.inverse(1.0, min, max);
358            assert!(
359                (recovered_min - min).abs() < 1e-8,
360                "{scale:?}: inverse(0) should be {min}, got {recovered_min}"
361            );
362            assert!(
363                (recovered_max - max).abs() < 1e-8,
364                "{scale:?}: inverse(1) should be {max}, got {recovered_max}"
365            );
366        }
367    }
368}