Skip to main content

motion_content/diagram/
scale.rs

1//! Validated one-dimensional data-to-drawing coordinates, independent of rendering.
2
3use motion_core::ValidationError;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6enum Mapping {
7    Linear,
8    Log10,
9}
10
11/// A stateless `f64` data domain mapped to an `f64` drawing-coordinate range.
12///
13/// Domain and range may ascend or descend but must have distinct finite endpoints.
14/// Logarithmic domains/values must be positive and their log endpoints distinct.
15/// Values extrapolate by default; [`Self::clamp`] bounds both mapping directions.
16/// Units are authored by the caller: this type does not convert meters to AU or
17/// add perspective, an orbital model, a clock, or previous-frame state.
18///
19/// ```
20/// use motion_content::diagram::Scale;
21/// let scale = Scale::log10((0.1, 1000.0), (100.0, 860.0))?;
22/// assert!((scale.map(10.0)? - 480.0).abs() < 0.001);
23/// assert!((scale.invert(480.0)? - 10.0).abs() < 1e-10);
24/// # Ok::<(), motion_core::ValidationError>(())
25/// ```
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct Scale {
28    domain: (f64, f64),
29    transformed: (f64, f64),
30    range: (f64, f64),
31    mapping: Mapping,
32    clamped: bool,
33}
34
35impl Scale {
36    /// Validates a linear mapping; endpoints may run in either direction.
37    pub fn linear(domain: (f64, f64), range: (f64, f64)) -> Result<Self, ValidationError> {
38        Self::new(domain, range, Mapping::Linear)
39    }
40
41    /// Validates a base-ten logarithmic mapping with positive domain endpoints.
42    pub fn log10(domain: (f64, f64), range: (f64, f64)) -> Result<Self, ValidationError> {
43        Self::new(domain, range, Mapping::Log10)
44    }
45
46    /// Replaces clamping for both map and invert; disabled by default.
47    /// Nonpositive log inputs remain invalid even with clamping enabled.
48    pub fn clamp(mut self, enabled: bool) -> Self {
49        self.clamped = enabled;
50        self
51    }
52
53    /// Returns the original, ordered data endpoints, without unit conversion.
54    pub fn domain(self) -> (f64, f64) {
55        self.domain
56    }
57
58    /// Returns the ordered drawing-coordinate endpoints.
59    pub fn range(self) -> (f64, f64) {
60        self.range
61    }
62
63    /// Maps a finite data value to drawing coordinates, preserving endpoints exactly.
64    /// Extrapolation that cannot be represented as a finite `f64` returns an error.
65    pub fn map(self, mut value: f64) -> Result<f64, ValidationError> {
66        if !value.is_finite() || (self.mapping == Mapping::Log10 && value <= 0.0) {
67            return Err(invalid());
68        }
69        if self.clamped {
70            value = value.clamp(
71                self.domain.0.min(self.domain.1),
72                self.domain.0.max(self.domain.1),
73            );
74        }
75        if value == self.domain.0 {
76            return Ok(self.range.0);
77        }
78        if value == self.domain.1 {
79            return Ok(self.range.1);
80        }
81        let value = match self.mapping {
82            Mapping::Linear => value,
83            Mapping::Log10 => libm::log10(value),
84        };
85        let fraction = normalize(value, self.transformed);
86        let mapped = interpolate(self.range, fraction);
87        if mapped.is_finite() {
88            Ok(mapped)
89        } else {
90            Err(invalid())
91        }
92    }
93
94    /// Maps finite drawing coordinates back into data units, with the same clamp policy.
95    pub fn invert(self, mut coordinate: f64) -> Result<f64, ValidationError> {
96        if !coordinate.is_finite() {
97            return Err(invalid());
98        }
99        if self.clamped {
100            coordinate = coordinate.clamp(
101                self.range.0.min(self.range.1),
102                self.range.0.max(self.range.1),
103            );
104        }
105        let fraction = normalize(coordinate, self.range);
106        self.value_at(fraction)
107    }
108
109    /// Returns 2 through 256 uniformly spaced positions in transformed data space.
110    /// Linear values are arithmetic; log values are geometric. Endpoints are exact.
111    /// These are not automatically rounded "nice" ticks. Requests whose data
112    /// values collapse at `f64` precision fail rather than returning duplicates.
113    pub fn ticks(self, count: usize) -> Result<Vec<f64>, ValidationError> {
114        if !(2..=256).contains(&count) {
115            return Err(invalid());
116        }
117        let mut ticks = Vec::with_capacity(count);
118        for index in 0..count {
119            let value = self.value_at(index as f64 / (count - 1) as f64)?;
120            if ticks.last().is_some_and(|previous| {
121                if self.domain.1 > self.domain.0 {
122                    value <= *previous
123                } else {
124                    value >= *previous
125                }
126            }) {
127                return Err(invalid());
128            }
129            ticks.push(value);
130        }
131        Ok(ticks)
132    }
133
134    /// Returns at most `max_count` aligned ticks without changing domain or range.
135    /// Accepts `2..=256`. Linear steps use 1, 2 or 5 times a power of ten;
136    /// logarithmic steps use integer powers of ten, skipping decades when needed.
137    /// Log domains containing fewer than two decade powers use linear nice steps.
138    /// Endpoints are not forced; a narrow domain may yield zero or one tick.
139    /// Unrepresentable steps/indices or collapsed values return an error.
140    pub fn nice_ticks(self, max_count: usize) -> Result<Vec<f64>, ValidationError> {
141        if !(2..=256).contains(&max_count) {
142            return Err(invalid());
143        }
144        let lower = self.domain.0.min(self.domain.1);
145        let upper = self.domain.0.max(self.domain.1);
146        let log_lower = self.transformed.0.min(self.transformed.1);
147        let log_upper = self.transformed.0.max(self.transformed.1);
148        let logarithmic =
149            self.mapping == Mapping::Log10 && log_upper.floor() - log_lower.ceil() >= 1.0;
150        let mut ticks = if logarithmic {
151            let step = ((log_upper.floor() - log_lower.ceil()) / (max_count - 1) as f64)
152                .ceil()
153                .max(1.0);
154            tick_grid((log_lower, log_upper), step, max_count)?
155                .into_iter()
156                .map(|exponent| libm::pow(10.0, exponent))
157                .filter(|value| *value >= lower && *value <= upper)
158                .collect::<Vec<_>>()
159        } else {
160            let intervals = (max_count - 1) as f64;
161            let span = upper - lower;
162            let raw = if span.is_finite() {
163                span / intervals
164            } else {
165                upper / intervals - lower / intervals
166            }
167            .max(f64::from_bits(1));
168            let base = libm::pow(10.0, libm::log10(raw).floor()).max(f64::from_bits(1));
169            let ratio = raw / base;
170            let factor = if ratio <= 1.0 {
171                1.0
172            } else if ratio <= 2.0 {
173                2.0
174            } else if ratio <= 5.0 {
175                5.0
176            } else {
177                10.0
178            };
179            let mut step = factor * base;
180            if (upper / step).floor() - (lower / step).ceil() + 1.0 > max_count as f64 {
181                step = if factor == 1.0 {
182                    2.0
183                } else if factor == 2.0 {
184                    5.0
185                } else if factor == 5.0 {
186                    10.0
187                } else {
188                    20.0
189                } * base;
190            }
191            tick_grid((lower, upper), step, max_count)?
192        };
193        if ticks.iter().any(|value| !value.is_finite())
194            || ticks.windows(2).any(|pair| pair[0] >= pair[1])
195        {
196            return Err(invalid());
197        }
198        if self.domain.0 > self.domain.1 {
199            ticks.reverse();
200        }
201        Ok(ticks)
202    }
203
204    fn new(
205        domain: (f64, f64),
206        range: (f64, f64),
207        mapping: Mapping,
208    ) -> Result<Self, ValidationError> {
209        if ![domain.0, domain.1, range.0, range.1]
210            .iter()
211            .all(|value| value.is_finite())
212            || domain.0 == domain.1
213            || range.0 == range.1
214            || (mapping == Mapping::Log10 && (domain.0 <= 0.0 || domain.1 <= 0.0))
215        {
216            return Err(invalid());
217        }
218        let transformed = match mapping {
219            Mapping::Linear => domain,
220            Mapping::Log10 => (libm::log10(domain.0), libm::log10(domain.1)),
221        };
222        if transformed.0 == transformed.1 {
223            return Err(invalid());
224        }
225        Ok(Self {
226            domain,
227            transformed,
228            range,
229            mapping,
230            clamped: false,
231        })
232    }
233
234    fn value_at(self, fraction: f64) -> Result<f64, ValidationError> {
235        if !fraction.is_finite() {
236            return Err(invalid());
237        }
238        if fraction == 0.0 {
239            return Ok(self.domain.0);
240        }
241        if fraction == 1.0 {
242            return Ok(self.domain.1);
243        }
244        let transformed = interpolate(self.transformed, fraction);
245        let value = match self.mapping {
246            Mapping::Linear => transformed,
247            Mapping::Log10 => libm::pow(10.0, transformed),
248        };
249        if value.is_finite() && (self.mapping == Mapping::Linear || value > 0.0) {
250            Ok(value)
251        } else {
252            Err(invalid())
253        }
254    }
255}
256
257fn tick_grid(bounds: (f64, f64), step: f64, max_count: usize) -> Result<Vec<f64>, ValidationError> {
258    if !step.is_finite() || step <= 0.0 {
259        return Err(invalid());
260    }
261    let first = (bounds.0 / step).ceil();
262    let last = (bounds.1 / step).floor();
263    if !first.is_finite()
264        || !last.is_finite()
265        || first.abs() > 9007199254740992.0
266        || last.abs() > 9007199254740992.0
267    {
268        return Err(invalid());
269    }
270    if first > last {
271        return Ok(Vec::new());
272    }
273    let count = last - first + 1.0;
274    if count > max_count as f64 {
275        return Err(invalid());
276    }
277    let mut ticks = Vec::with_capacity(count as usize);
278    for offset in 0..count as usize {
279        let value = (first + offset as f64) * step;
280        if value >= bounds.0 && value <= bounds.1 {
281            ticks.push(if value == 0.0 { 0.0 } else { value });
282        }
283    }
284    Ok(ticks)
285}
286
287fn normalize(value: f64, endpoints: (f64, f64)) -> f64 {
288    let span = endpoints.1 - endpoints.0;
289    let offset = value - endpoints.0;
290    if span.is_finite() && offset.is_finite() {
291        offset / span
292    } else {
293        (value * 0.5 - endpoints.0 * 0.5) / (endpoints.1 * 0.5 - endpoints.0 * 0.5)
294    }
295}
296
297fn interpolate(endpoints: (f64, f64), fraction: f64) -> f64 {
298    let span = endpoints.1 - endpoints.0;
299    if span.is_finite() {
300        endpoints.0 + span * fraction
301    } else {
302        endpoints.0 * (1.0 - fraction) + endpoints.1 * fraction
303    }
304}
305
306fn invalid() -> ValidationError {
307    ValidationError::invalid(
308        "scale",
309        "domain",
310        "Scale requires distinct finite endpoints, positive log data, representable results/steps/indices and a tick count in 2..=256",
311    )
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn nice_linear_ticks_use_aligned_steps_without_forcing_endpoints() -> Result<(), ValidationError>
320    {
321        assert_eq!(
322            Scale::linear((3.0, 97.0), (0.0, 100.0))?.nice_ticks(6)?,
323            [20.0, 40.0, 60.0, 80.0]
324        );
325        assert_eq!(
326            Scale::linear((-97.0, -3.0), (0.0, 100.0))?.nice_ticks(6)?,
327            [-80.0, -60.0, -40.0, -20.0]
328        );
329        let ticks = Scale::linear((-3.0, 9.0), (0.0, 100.0))?.nice_ticks(5)?;
330        assert_eq!(ticks, [0.0, 5.0]);
331        assert_eq!(ticks[0].to_bits(), 0.0_f64.to_bits());
332        Ok(())
333    }
334
335    #[test]
336    fn nice_log_ticks_select_decades_and_fall_back_for_narrow_domains()
337    -> Result<(), ValidationError> {
338        let log = Scale::log10((0.1, 1000.0), (100.0, 860.0))?;
339        assert_eq!(log.nice_ticks(5)?, [0.1, 1.0, 10.0, 100.0, 1000.0]);
340        assert_eq!(log.nice_ticks(3)?, [1.0, 100.0]);
341        assert_eq!(
342            Scale::log10((2.0, 8.0), (0.0, 100.0))?.nice_ticks(5)?,
343            [2.0, 4.0, 6.0, 8.0]
344        );
345        Ok(())
346    }
347
348    #[test]
349    fn nice_ticks_follow_domain_order_not_range_clamp_or_evaluation_order()
350    -> Result<(), ValidationError> {
351        let forward = Scale::linear((3.0, 97.0), (100.0, 0.0))?;
352        let backward = Scale::linear((97.0, 3.0), (0.0, 100.0))?;
353        assert_eq!(backward.nice_ticks(6)?, [80.0, 60.0, 40.0, 20.0]);
354        let original = forward.nice_ticks(6)?;
355        for count in [2, 256, 3, 6] {
356            forward.nice_ticks(count)?;
357        }
358        assert_eq!(forward.clamp(true).nice_ticks(6)?, original);
359        assert_eq!(forward.nice_ticks(6)?, original);
360        assert_eq!(forward.domain(), (3.0, 97.0));
361        assert_eq!(forward.range(), (100.0, 0.0));
362        Ok(())
363    }
364
365    #[test]
366    fn nice_ticks_are_bounded_finite_ordered_and_inside_extreme_domains()
367    -> Result<(), ValidationError> {
368        for scale in [
369            Scale::linear((-1e100, 1e100), (0.0, 100.0))?,
370            Scale::linear((-0.07, 0.23), (0.0, 100.0))?,
371            Scale::linear((1e-250, 9e-250), (0.0, 100.0))?,
372            Scale::linear((f64::from_bits(1), f64::from_bits(5)), (0.0, 100.0))?,
373            Scale::log10((1e-300, 1e300), (0.0, 100.0))?,
374        ] {
375            for count in 2..=256 {
376                let ticks = scale.nice_ticks(count)?;
377                assert!(ticks.len() <= count);
378                assert!(ticks.windows(2).all(|pair| pair[0] < pair[1]));
379                assert!(ticks.iter().all(|value| value.is_finite()
380                    && *value >= scale.domain().0
381                    && *value <= scale.domain().1));
382            }
383        }
384        Ok(())
385    }
386
387    #[test]
388    fn nice_ticks_reject_invalid_budgets_and_unrepresentable_grid_steps_or_indices()
389    -> Result<(), ValidationError> {
390        let scale = Scale::linear((0.0, 100.0), (0.0, 100.0))?;
391        assert!(scale.nice_ticks(1).is_err());
392        assert!(scale.nice_ticks(257).is_err());
393        assert!(
394            Scale::linear((1.0, 1.0 + f64::EPSILON), (0.0, 100.0))?
395                .nice_ticks(5)
396                .is_err()
397        );
398        assert!(
399            Scale::linear((0.0, f64::MAX), (0.0, 100.0))?
400                .nice_ticks(2)
401                .is_err()
402        );
403        Ok(())
404    }
405
406    #[test]
407    fn linear_and_logarithmic_mappings_have_exact_endpoints_and_correct_midpoints()
408    -> Result<(), ValidationError> {
409        let linear = Scale::linear((0.0, 100.0), (20.0, 220.0))?;
410        assert_eq!(linear.map(50.0)?, 120.0);
411        assert_eq!(linear.invert(120.0)?, 50.0);
412        let log = Scale::log10((0.1, 1000.0), (100.0, 860.0))?;
413        assert_eq!(log.map(0.1)?, 100.0);
414        assert_eq!(log.map(1000.0)?, 860.0);
415        assert_eq!(log.invert(100.0)?, 0.1);
416        assert_eq!(log.invert(860.0)?, 1000.0);
417        assert!((log.map(10.0)? - 480.0).abs() < 0.001);
418        assert!((log.invert(480.0)? - 10.0).abs() < 1e-10);
419        Ok(())
420    }
421
422    #[test]
423    fn reversed_domains_ranges_and_clamping_are_explicit_and_replaceable()
424    -> Result<(), ValidationError> {
425        let scale = Scale::linear((100.0, 0.0), (0.0, 200.0))?;
426        assert_eq!(scale.map(75.0)?, 50.0);
427        assert_eq!(scale.map(150.0)?, -100.0);
428        assert_eq!(scale.clamp(true).map(150.0)?, 0.0);
429        assert_eq!(scale.clamp(true).invert(300.0)?, 0.0);
430        assert_eq!(scale.clamp(true).clamp(false), scale);
431        let log = Scale::log10((1.0, 100.0), (200.0, 0.0))?;
432        assert!((log.map(10.0)? - 100.0).abs() < 0.001);
433        assert_eq!(log.clamp(true).map(1000.0)?, 0.0);
434        assert_eq!(log.clamp(true).invert(300.0)?, 1.0);
435        assert!(log.clamp(true).map(0.0).is_err());
436        Ok(())
437    }
438
439    #[test]
440    fn huge_small_and_opposite_signed_domains_do_not_need_f32_data_coordinates()
441    -> Result<(), ValidationError> {
442        let scale = Scale::linear((-f64::MAX, f64::MAX), (0.0, 100.0))?;
443        assert_eq!(scale.map(0.0)?, 50.0);
444        assert_eq!(scale.invert(50.0)?, 0.0);
445        let scale = Scale::linear((0.0, 1e30), (0.0, 1000.0))?;
446        assert!((scale.map(5e29)? - 500.0).abs() < 0.001);
447        let scale = Scale::log10((1e-300, 1e300), (0.0, 600.0))?;
448        assert_eq!(scale.map(1.0)?, 300.0);
449        assert_eq!(scale.invert(300.0)?, 1.0);
450        let scale = Scale::linear((f64::from_bits(1), f64::from_bits(2)), (0.0, 100.0))?;
451        assert_eq!(scale.map(f64::from_bits(1))?, 0.0);
452        assert_eq!(scale.invert(100.0)?, f64::from_bits(2));
453        Ok(())
454    }
455
456    #[test]
457    fn ticks_are_bounded_ordered_data_values_not_rounded_or_pixel_quantized()
458    -> Result<(), ValidationError> {
459        let linear = Scale::linear((0.0, 100.0), (20.0, 220.0))?;
460        assert_eq!(linear.ticks(5)?, [0.0, 25.0, 50.0, 75.0, 100.0]);
461        let descending = Scale::linear((100.0, 0.0), (20.0, 220.0))?;
462        assert_eq!(descending.ticks(3)?, [100.0, 50.0, 0.0]);
463        let log = Scale::log10((1.0, 10000.0), (0.0, 1.0))?;
464        assert_eq!(log.ticks(5)?, [1.0, 10.0, 100.0, 1000.0, 10000.0]);
465        assert!(linear.ticks(1).is_err());
466        assert!(linear.ticks(257).is_err());
467        let narrow = Scale::linear((1.0, 1.0 + f64::EPSILON), (0.0, 100.0))?;
468        assert!(narrow.ticks(3).is_err());
469        Ok(())
470    }
471
472    #[test]
473    fn finite_round_trips_replay_in_any_order() -> Result<(), ValidationError> {
474        for scale in [
475            Scale::linear((-20.0, 500.0), (40.0, 800.0))?,
476            Scale::log10((0.01, 1e6), (800.0, 40.0))?,
477        ] {
478            for coordinate in [420.0, 40.0, 800.0, 200.0, 420.0] {
479                let value = scale.invert(coordinate)?;
480                assert!((scale.map(value)? - coordinate).abs() < 0.001);
481            }
482        }
483        Ok(())
484    }
485
486    #[test]
487    fn drawing_coordinates_retain_precision_beyond_f32() -> Result<(), ValidationError> {
488        let scale = Scale::linear((0.0, 1.0), (100_000_000.125, 100_000_000.625))?;
489        assert_eq!(scale.map(0.5)?, 100_000_000.375);
490        assert_eq!(scale.invert(100_000_000.375)?, 0.5);
491        assert_eq!(scale.map(0.5)?, scale.map(0.5)?);
492        Ok(())
493    }
494
495    #[test]
496    fn invalid_domains_inputs_and_unrepresentable_extrapolation_fail() -> Result<(), ValidationError>
497    {
498        assert!(Scale::linear((1.0, 1.0), (0.0, 100.0)).is_err());
499        assert!(Scale::linear((0.0, f64::INFINITY), (0.0, 100.0)).is_err());
500        assert!(Scale::linear((0.0, 1.0), (2.0, 2.0)).is_err());
501        assert!(Scale::linear((0.0, 1.0), (f64::NAN, 2.0)).is_err());
502        assert!(Scale::log10((0.0, 100.0), (0.0, 100.0)).is_err());
503        assert!(Scale::log10((-1.0, 100.0), (0.0, 100.0)).is_err());
504        let scale = Scale::linear((0.0, 1.0), (0.0, 100.0))?;
505        assert!(scale.map(f64::NAN).is_err());
506        assert!(scale.map(f64::MAX).is_err());
507        assert!(scale.invert(f64::INFINITY).is_err());
508        assert!(
509            Scale::log10((1.0, 10.0), (0.0, 1.0))?
510                .invert(1000.0)
511                .is_err()
512        );
513        Ok(())
514    }
515}