1#[derive(Debug, Clone, Default)]
5#[non_exhaustive]
6pub enum Scale {
7 #[default]
9 Linear,
10 Log10,
12 SymLog {
14 linthresh: f64,
16 },
17 Time,
21}
22
23impl Scale {
24 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 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 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 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 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 #[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 #[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 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 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 #[test]
209 fn symlog_zero_maps_correctly() {
210 let s = Scale::SymLog { linthresh: 1.0 };
211 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 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 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 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 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 #[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}