1#[derive(Debug, Clone, Default)]
5#[non_exhaustive]
6pub enum Scale {
7 #[default]
9 Linear,
10 Log10,
12 SymLog {
14 linthresh: f64,
16 },
17}
18
19impl Scale {
20 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 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 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 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 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 #[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 #[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 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 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 #[test]
205 fn symlog_zero_maps_correctly() {
206 let s = Scale::SymLog { linthresh: 1.0 };
207 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 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 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 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 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 #[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}