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