Skip to main content

esoc_scene/
scale.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Scales: data → visual coordinate mapping.
3
4/// A scale maps data values to visual coordinates.
5///
6/// Data stays f64 (scientific precision) until mapped through a Scale
7/// to f32 visual coordinates.
8#[derive(Clone, Debug)]
9pub enum Scale {
10    /// Linear mapping.
11    Linear {
12        /// Data domain `(min, max)`.
13        domain: (f64, f64),
14        /// Visual range `(min, max)` in pixels.
15        range: (f32, f32),
16    },
17    /// Logarithmic mapping.
18    Log {
19        /// Data domain `(min, max)` — must be positive.
20        domain: (f64, f64),
21        /// Visual range `(min, max)`.
22        range: (f32, f32),
23        /// Log base (typically 10 or e).
24        base: f64,
25    },
26    /// Band scale for categorical data.
27    Band {
28        /// Category labels.
29        domain: Vec<String>,
30        /// Visual range.
31        range: (f32, f32),
32        /// Padding between bands as fraction `[0, 1)`.
33        padding: f32,
34    },
35    /// Time scale (Unix milliseconds).
36    Time {
37        /// Domain as Unix ms `(start, end)`.
38        domain: (i64, i64),
39        /// Visual range.
40        range: (f32, f32),
41    },
42    /// Square root mapping (emphasizes smaller values).
43    Sqrt {
44        /// Data domain `(min, max)`.
45        domain: (f64, f64),
46        /// Visual range.
47        range: (f32, f32),
48    },
49    /// Power mapping with configurable exponent.
50    Power {
51        /// Data domain `(min, max)`.
52        domain: (f64, f64),
53        /// Visual range.
54        range: (f32, f32),
55        /// Exponent (1 = linear, 2 = quadratic, 0.5 = sqrt).
56        exponent: f64,
57    },
58    /// Symmetric log: handles positive, negative, and zero.
59    Symlog {
60        /// Data domain `(min, max)`.
61        domain: (f64, f64),
62        /// Visual range.
63        range: (f32, f32),
64        /// Linearity threshold constant.
65        constant: f64,
66    },
67    /// Ordinal: maps discrete string values to specific positions.
68    Ordinal {
69        /// Ordered labels.
70        domain: Vec<String>,
71        /// Corresponding pixel positions.
72        range: Vec<f32>,
73    },
74}
75
76impl Scale {
77    /// Map a continuous data value to a visual coordinate.
78    pub fn map(&self, value: f64) -> f32 {
79        match self {
80            Self::Linear { domain, range } => {
81                let t = if (domain.1 - domain.0).abs() < 1e-15 {
82                    0.5
83                } else {
84                    (value - domain.0) / (domain.1 - domain.0)
85                };
86                range.0 + (range.1 - range.0) * t as f32
87            }
88            Self::Log {
89                domain,
90                range,
91                base,
92            } => {
93                let log_val = value.max(1e-15).log(*base);
94                let log_min = domain.0.max(1e-15).log(*base);
95                let log_max = domain.1.max(1e-15).log(*base);
96                let t = if (log_max - log_min).abs() < 1e-15 {
97                    0.5
98                } else {
99                    (log_val - log_min) / (log_max - log_min)
100                };
101                range.0 + (range.1 - range.0) * t as f32
102            }
103            Self::Band {
104                domain,
105                range,
106                padding,
107            } => {
108                // Return the center of the band
109                if domain.is_empty() {
110                    return (range.0 + range.1) * 0.5;
111                }
112                let total = range.1 - range.0;
113                let n = domain.len() as f32;
114                let band_width = total / (n + (n + 1.0) * padding);
115                let step = band_width + band_width * padding;
116                // Find index (default to 0)
117                let idx = domain
118                    .iter()
119                    .position(|s| {
120                        // Match against stringified value
121                        let v_str = format!("{value}");
122                        s == &v_str
123                    })
124                    .unwrap_or(0) as f32;
125                range.0 + step * padding + idx * step + band_width * 0.5
126            }
127            Self::Time { domain, range } => {
128                let t = if domain.1 == domain.0 {
129                    0.5
130                } else {
131                    (value as i64 - domain.0) as f64 / (domain.1 - domain.0) as f64
132                };
133                range.0 + (range.1 - range.0) * t as f32
134            }
135            Self::Sqrt { domain, range } => {
136                let sqrt_val = value.max(0.0).sqrt();
137                let sqrt_min = domain.0.max(0.0).sqrt();
138                let sqrt_max = domain.1.max(0.0).sqrt();
139                let t = if (sqrt_max - sqrt_min).abs() < 1e-15 {
140                    0.5
141                } else {
142                    (sqrt_val - sqrt_min) / (sqrt_max - sqrt_min)
143                };
144                range.0 + (range.1 - range.0) * t as f32
145            }
146            Self::Power {
147                domain,
148                range,
149                exponent,
150            } => {
151                let pow_val = value.max(0.0).powf(*exponent);
152                let pow_min = domain.0.max(0.0).powf(*exponent);
153                let pow_max = domain.1.max(0.0).powf(*exponent);
154                let t = if (pow_max - pow_min).abs() < 1e-15 {
155                    0.5
156                } else {
157                    (pow_val - pow_min) / (pow_max - pow_min)
158                };
159                range.0 + (range.1 - range.0) * t as f32
160            }
161            Self::Symlog {
162                domain,
163                range,
164                constant,
165            } => {
166                let symlog = |v: f64| v.signum() * (v.abs() / constant).ln_1p();
167                let sl_val = symlog(value);
168                let sl_min = symlog(domain.0);
169                let sl_max = symlog(domain.1);
170                let t = if (sl_max - sl_min).abs() < 1e-15 {
171                    0.5
172                } else {
173                    (sl_val - sl_min) / (sl_max - sl_min)
174                };
175                range.0 + (range.1 - range.0) * t as f32
176            }
177            Self::Ordinal { domain, range } => {
178                // Map by index lookup from stringified value
179                let idx = domain
180                    .iter()
181                    .position(|s| {
182                        let v_str = format!("{value}");
183                        s == &v_str
184                    })
185                    .unwrap_or(0);
186                range.get(idx).copied().unwrap_or(0.0)
187            }
188        }
189    }
190
191    /// Map a band category to its center position and width.
192    pub fn map_band(&self, category: &str) -> Option<(f32, f32)> {
193        match self {
194            Self::Band {
195                domain,
196                range,
197                padding,
198            } => {
199                let idx = domain.iter().position(|s| s == category)?;
200                let total = range.1 - range.0;
201                let n = domain.len() as f32;
202                let band_width = total / (n + (n + 1.0) * padding);
203                let step = band_width + band_width * padding;
204                let center = range.0 + step * padding + idx as f32 * step + band_width * 0.5;
205                Some((center, band_width))
206            }
207            _ => None,
208        }
209    }
210
211    /// Invert: map from visual coordinate back to data value.
212    pub fn invert(&self, visual: f32) -> f64 {
213        match self {
214            Self::Linear { domain, range } => {
215                let t = if (range.1 - range.0).abs() < 1e-10 {
216                    0.5
217                } else {
218                    (visual - range.0) / (range.1 - range.0)
219                };
220                domain.0 + (domain.1 - domain.0) * f64::from(t)
221            }
222            Self::Log {
223                domain,
224                range,
225                base,
226            } => {
227                let t = if (range.1 - range.0).abs() < 1e-10 {
228                    0.5
229                } else {
230                    (visual - range.0) / (range.1 - range.0)
231                };
232                let log_min = domain.0.max(1e-15).log(*base);
233                let log_max = domain.1.max(1e-15).log(*base);
234                let log_val = log_min + (log_max - log_min) * f64::from(t);
235                base.powf(log_val)
236            }
237            Self::Time { domain, range } => {
238                let t = if (range.1 - range.0).abs() < 1e-10 {
239                    0.5
240                } else {
241                    (visual - range.0) / (range.1 - range.0)
242                };
243                domain.0 as f64 + (domain.1 - domain.0) as f64 * f64::from(t)
244            }
245            Self::Band { .. } | Self::Ordinal { .. } => 0.0, // Not invertible
246            Self::Sqrt { domain, range } => {
247                let t = if (range.1 - range.0).abs() < 1e-10 {
248                    0.5
249                } else {
250                    (visual - range.0) / (range.1 - range.0)
251                };
252                let sqrt_min = domain.0.max(0.0).sqrt();
253                let sqrt_max = domain.1.max(0.0).sqrt();
254                let sqrt_val = sqrt_min + (sqrt_max - sqrt_min) * f64::from(t);
255                sqrt_val * sqrt_val
256            }
257            Self::Power {
258                domain,
259                range,
260                exponent,
261            } => {
262                let t = if (range.1 - range.0).abs() < 1e-10 {
263                    0.5
264                } else {
265                    (visual - range.0) / (range.1 - range.0)
266                };
267                let pow_min = domain.0.max(0.0).powf(*exponent);
268                let pow_max = domain.1.max(0.0).powf(*exponent);
269                let pow_val = pow_min + (pow_max - pow_min) * f64::from(t);
270                pow_val.powf(1.0 / exponent)
271            }
272            Self::Symlog {
273                domain,
274                range,
275                constant,
276            } => {
277                let symlog = |v: f64| v.signum() * (v.abs() / constant).ln_1p();
278                let t = if (range.1 - range.0).abs() < 1e-10 {
279                    0.5
280                } else {
281                    (visual - range.0) / (range.1 - range.0)
282                };
283                let sl_min = symlog(domain.0);
284                let sl_max = symlog(domain.1);
285                let sl_val = sl_min + (sl_max - sl_min) * f64::from(t);
286                // Inverse of symlog: sign(y) * c * (exp(|y|) - 1)
287                sl_val.signum() * constant * (sl_val.abs()).exp_m1()
288            }
289        }
290    }
291
292    /// Generate nice tick positions.
293    pub fn ticks(&self, target_count: usize) -> Vec<f64> {
294        match self {
295            Self::Linear { domain, .. }
296            | Self::Sqrt { domain, .. }
297            | Self::Power { domain, .. }
298            | Self::Symlog { domain, .. } => nice_ticks_linear(domain.0, domain.1, target_count),
299            Self::Log { domain, base, .. } => nice_ticks_log(domain.0, domain.1, *base),
300            Self::Band { domain, .. } => (0..domain.len()).map(|i| i as f64).collect(),
301            Self::Time { domain, .. } => {
302                nice_ticks_linear(domain.0 as f64, domain.1 as f64, target_count)
303            }
304            Self::Ordinal { domain, .. } => (0..domain.len()).map(|i| i as f64).collect(),
305        }
306    }
307
308    /// Extend the domain to nice round boundaries so ticks align with domain edges.
309    ///
310    /// For Linear scales, this expands the domain outward to the nearest nice
311    /// tick boundaries using D3-style tick step computation. After nicing,
312    /// `ticks()` will never produce values outside the domain.
313    pub fn nice(&self, target_count: usize) -> Self {
314        match self {
315            Self::Linear { domain, range } => {
316                let (min, max) = *domain;
317                if (max - min).abs() < 1e-15 {
318                    return self.clone();
319                }
320                let step = tick_step(min, max, target_count);
321                let nice_min = (min / step).floor() * step;
322                let nice_max = (max / step).ceil() * step;
323                Self::Linear {
324                    domain: (nice_min, nice_max),
325                    range: *range,
326                }
327            }
328            // Other scale types: return unchanged for now
329            _ => self.clone(),
330        }
331    }
332
333    /// Format a tick value as a label.
334    pub fn format_tick(&self, value: f64) -> String {
335        match self {
336            Self::Band { domain, .. } | Self::Ordinal { domain, .. } => {
337                let idx = value as usize;
338                domain.get(idx).cloned().unwrap_or_default()
339            }
340            _ => format_number(value),
341        }
342    }
343}
344
345/// Generate nice linear tick positions using D3-style tick step.
346fn nice_ticks_linear(min: f64, max: f64, target_count: usize) -> Vec<f64> {
347    if (max - min).abs() < 1e-15 {
348        return vec![min];
349    }
350
351    let step = tick_step(min, max, target_count);
352
353    let graph_min = (min / step).floor() * step;
354    let graph_max = (max / step).ceil() * step;
355
356    let mut positions = Vec::new();
357    let mut v = graph_min;
358    let max_ticks = (target_count + 5) * 2;
359    while v <= graph_max + step * 0.5 && positions.len() < max_ticks {
360        positions.push(v);
361        v += step;
362    }
363    positions
364}
365
366/// Generate log tick positions.
367fn nice_ticks_log(min: f64, max: f64, base: f64) -> Vec<f64> {
368    let log_min = min.max(1e-15).log(base).floor() as i32;
369    let log_max = max.max(1e-15).log(base).ceil() as i32;
370    (log_min..=log_max).map(|e| base.powi(e)).collect()
371}
372
373/// Compute a tick step size for a domain `[start, stop]` aiming for `count` ticks.
374///
375/// Uses D3-style algorithm: divides the raw range by count to get a candidate
376/// step, then rounds to the nearest 1/2/5/10 multiple. This avoids the
377/// Heckbert overshoot where rounding the range itself before dividing
378/// inflates the step.
379fn tick_step(start: f64, stop: f64, count: usize) -> f64 {
380    let step0 = (stop - start).abs() / count.max(1) as f64;
381    let mut step1 = 10.0_f64.powf(step0.log10().floor());
382    let error = step0 / step1;
383    if error >= 50.0_f64.sqrt() {
384        // ~7.07
385        step1 *= 10.0;
386    } else if error >= 10.0_f64.sqrt() {
387        // ~3.16
388        step1 *= 5.0;
389    } else if error >= 2.0_f64.sqrt() {
390        // ~1.41
391        step1 *= 2.0;
392    }
393    step1
394}
395
396/// Compute a "nice" number approximately equal to `x`.
397#[allow(dead_code)]
398fn nice_num(x: f64, round: bool) -> f64 {
399    let exp = x.abs().log10().floor();
400    let frac = x / 10.0_f64.powf(exp);
401
402    let nice_frac = if round {
403        if frac < 1.5 {
404            1.0
405        } else if frac < 3.0 {
406            2.0
407        } else if frac < 7.0 {
408            5.0
409        } else {
410            10.0
411        }
412    } else if frac <= 1.0 {
413        1.0
414    } else if frac <= 2.0 {
415        2.0
416    } else if frac <= 5.0 {
417        5.0
418    } else {
419        10.0
420    };
421
422    nice_frac * 10.0_f64.powf(exp)
423}
424
425/// Format a number as a concise tick label.
426///
427/// Uses SI prefixes for large values, comma grouping for mid-range,
428/// and scientific notation only for very small numbers.
429pub fn format_number(value: f64) -> String {
430    if value == 0.0 {
431        return "0".to_string();
432    }
433    let abs = value.abs();
434    let sign = if value < 0.0 { "-" } else { "" };
435
436    if abs >= 1e9 {
437        let v = value / 1e9;
438        return format_si(v, sign, "B");
439    }
440    if abs >= 1e6 {
441        let v = value / 1e6;
442        return format_si(v, sign, "M");
443    }
444    if abs >= 1e4 {
445        // Comma-grouped integer
446        let rounded = value.round() as i64;
447        return format_with_commas(rounded);
448    }
449    if abs >= 1.0 {
450        // Integer or one decimal
451        if (value - value.round()).abs() < 1e-9 {
452            return format!("{}", value as i64);
453        }
454        return format!("{value:.1}");
455    }
456    if abs >= 0.01 {
457        return format!("{value:.2}");
458    }
459    if abs >= 1e-6 {
460        // Small but not tiny — use enough decimals
461        let decimals = (-abs.log10().floor() as usize) + 2;
462        return format!("{value:.prec$}", prec = decimals.min(8));
463    }
464    // Very small: scientific notation
465    format!("{value:.2e}")
466}
467
468/// Format a value with an SI suffix, trimming trailing zeros.
469fn format_si(v: f64, sign: &str, suffix: &str) -> String {
470    if (v.abs() - v.abs().round()).abs() < 0.05 {
471        format!("{sign}{}{suffix}", v.abs().round() as i64)
472    } else {
473        format!("{sign}{:.1}{suffix}", v.abs())
474    }
475}
476
477/// Format an integer with comma grouping (e.g. 12345 → "12,345").
478fn format_with_commas(value: i64) -> String {
479    let neg = value < 0;
480    let s = value.unsigned_abs().to_string();
481    let bytes = s.as_bytes();
482    let mut result = String::with_capacity(s.len() + s.len() / 3);
483    if neg {
484        result.push('-');
485    }
486    for (i, &b) in bytes.iter().enumerate() {
487        if i > 0 && (bytes.len() - i) % 3 == 0 {
488            result.push(',');
489        }
490        result.push(b as char);
491    }
492    result
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn linear_map() {
501        let s = Scale::Linear {
502            domain: (0.0, 100.0),
503            range: (0.0, 500.0),
504        };
505        assert!((s.map(50.0) - 250.0).abs() < 1e-3);
506        assert!((s.map(0.0)).abs() < 1e-3);
507        assert!((s.map(100.0) - 500.0).abs() < 1e-3);
508    }
509
510    #[test]
511    fn linear_invert() {
512        let s = Scale::Linear {
513            domain: (0.0, 100.0),
514            range: (0.0, 500.0),
515        };
516        assert!((s.invert(250.0) - 50.0).abs() < 1e-3);
517    }
518
519    #[test]
520    fn log_map() {
521        let s = Scale::Log {
522            domain: (1.0, 1000.0),
523            range: (0.0, 300.0),
524            base: 10.0,
525        };
526        assert!((s.map(1.0)).abs() < 1e-3);
527        assert!((s.map(1000.0) - 300.0).abs() < 1e-3);
528        // 10 should be at 1/3
529        assert!((s.map(10.0) - 100.0).abs() < 1e-3);
530    }
531
532    #[test]
533    fn band_map() {
534        let s = Scale::Band {
535            domain: vec!["A".into(), "B".into(), "C".into()],
536            range: (0.0, 300.0),
537            padding: 0.1,
538        };
539        let (center_a, width) = s.map_band("A").unwrap();
540        let (center_b, _) = s.map_band("B").unwrap();
541        assert!(center_a < center_b);
542        assert!(width > 0.0);
543    }
544
545    #[test]
546    fn sqrt_map() {
547        let s = Scale::Sqrt {
548            domain: (0.0, 100.0),
549            range: (0.0, 500.0),
550        };
551        assert!((s.map(0.0)).abs() < 1e-3);
552        assert!((s.map(100.0) - 500.0).abs() < 1e-3);
553        // sqrt(25) = 5, sqrt(100) = 10, so t = 5/10 = 0.5 → 250
554        assert!((s.map(25.0) - 250.0).abs() < 1e-3);
555    }
556
557    #[test]
558    fn symlog_map() {
559        let s = Scale::Symlog {
560            domain: (-100.0, 100.0),
561            range: (0.0, 500.0),
562            constant: 1.0,
563        };
564        // 0 should map to midpoint
565        let mid = s.map(0.0);
566        assert!((mid - 250.0).abs() < 1e-3, "mid = {mid}");
567        // Symmetric: map(-x) + map(x) should equal 2*mid
568        let pos = s.map(50.0);
569        let neg = s.map(-50.0);
570        assert!((pos + neg - 500.0).abs() < 1e-2, "pos={pos}, neg={neg}");
571    }
572
573    #[test]
574    fn power_map() {
575        let s = Scale::Power {
576            domain: (0.0, 10.0),
577            range: (0.0, 100.0),
578            exponent: 2.0,
579        };
580        assert!((s.map(0.0)).abs() < 1e-3);
581        assert!((s.map(10.0) - 100.0).abs() < 1e-3);
582        // Power(5, 2) = 25, Power(10, 2) = 100, t = 25/100 = 0.25 → 25
583        assert!((s.map(5.0) - 25.0).abs() < 1e-3);
584    }
585
586    #[test]
587    fn ordinal_map() {
588        let s = Scale::Ordinal {
589            domain: vec!["low".into(), "med".into(), "high".into()],
590            range: vec![50.0, 150.0, 250.0],
591        };
592        // Ordinal maps by string lookup, these aren't numeric so we test map_band indirectly
593        // The ticks function should return indices
594        let ticks = s.ticks(3);
595        assert_eq!(ticks, vec![0.0, 1.0, 2.0]);
596        assert_eq!(s.format_tick(0.0), "low");
597        assert_eq!(s.format_tick(2.0), "high");
598    }
599
600    #[test]
601    fn nice_ticks() {
602        let s = Scale::Linear {
603            domain: (0.0, 100.0),
604            range: (0.0, 500.0),
605        };
606        let ticks = s.ticks(5);
607        assert!(!ticks.is_empty());
608        assert!(ticks[0] <= 0.0);
609        assert!(*ticks.last().unwrap() >= 100.0);
610    }
611
612    #[test]
613    fn nice_expands_domain_to_tick_boundaries() {
614        let s = Scale::Linear {
615            domain: (3.7, 97.2),
616            range: (0.0, 500.0),
617        };
618        let niced = s.nice(5);
619        let Scale::Linear { domain, .. } = &niced else {
620            panic!("expected Linear");
621        };
622        // Niced domain should be at nice round numbers that contain the original
623        assert!(domain.0 <= 3.7, "niced min {} should be <= 3.7", domain.0);
624        assert!(domain.1 >= 97.2, "niced max {} should be >= 97.2", domain.1);
625        // All ticks from the niced scale should be within the niced domain
626        let ticks = niced.ticks(5);
627        for &t in &ticks {
628            assert!(
629                t >= domain.0 - 1e-9 && t <= domain.1 + 1e-9,
630                "tick {} outside niced domain [{}, {}]",
631                t,
632                domain.0,
633                domain.1,
634            );
635        }
636    }
637}