Skip to main content

ggplot_rs/coord/
trans.rs

1use crate::render::Rect;
2use crate::scale::transform::ScaleTransform;
3
4use super::{AxisSpan, Coord};
5
6/// Transformed Cartesian coordinates (R's `coord_trans`).
7///
8/// Unlike a *scale* transform (which transforms data before stats), this warps
9/// the **coordinate space at draw time** — stats are computed on the raw data
10/// but drawn on a non-linear axis. Data points, gridlines, and ticks all pass
11/// through `transform`, so they warp consistently and the tick labels keep their
12/// original (untransformed) values.
13///
14/// The trained data domain is supplied via [`Coord::set_domains`] during build,
15/// so the warp is faithful. If a transform is invalid over the domain (e.g.
16/// `log10` with a non-positive minimum) the axis falls back to linear.
17pub struct CoordTrans {
18    x: Option<ScaleTransform>,
19    y: Option<ScaleTransform>,
20    x_span: Option<AxisSpan>,
21    y_span: Option<AxisSpan>,
22}
23
24impl CoordTrans {
25    pub fn new(x: Option<ScaleTransform>, y: Option<ScaleTransform>) -> Self {
26        CoordTrans {
27            x,
28            y,
29            x_span: None,
30            y_span: None,
31        }
32    }
33}
34
35/// Warp a normalized position `n` by applying `trans` across the domain.
36///
37/// The scale maps data linearly onto `[pmin, pmax]` (with expansion margins
38/// outside), so we invert that to recover the data value, apply the transform
39/// within the domain, and place the result back into `[pmin, pmax]`. Positions
40/// in the expansion margins, or where the transform is undefined, stay linear.
41fn warp(n: f64, trans: &Option<ScaleTransform>, span: Option<AxisSpan>) -> f64 {
42    let (trans, s) = match (trans, span) {
43        (Some(t), Some(s)) if s.max > s.min && (s.pmax - s.pmin).abs() > 1e-12 => (t, s),
44        _ => return n,
45    };
46    let v = s.min + (n - s.pmin) / (s.pmax - s.pmin) * (s.max - s.min);
47    if v < s.min || v > s.max {
48        return n; // expansion margin — keep linear
49    }
50    let (fmin, fmax, fv) = (trans.apply(s.min), trans.apply(s.max), trans.apply(v));
51    if fmin.is_finite() && fmax.is_finite() && fv.is_finite() && (fmax - fmin).abs() > 1e-12 {
52        let tf = (fv - fmin) / (fmax - fmin);
53        s.pmin + tf * (s.pmax - s.pmin)
54    } else {
55        n
56    }
57}
58
59impl Coord for CoordTrans {
60    fn transform(&self, point: (f64, f64), plot_area: &Rect) -> (f64, f64) {
61        let wx = warp(point.0, &self.x, self.x_span);
62        let wy = warp(point.1, &self.y, self.y_span);
63        let px = plot_area.x + wx * plot_area.width;
64        let py = plot_area.y + (1.0 - wy) * plot_area.height;
65        (px, py)
66    }
67
68    fn set_domains(&mut self, x: Option<AxisSpan>, y: Option<AxisSpan>) {
69        self.x_span = x;
70        self.y_span = y;
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn area() -> Rect {
79        Rect {
80            x: 0.0,
81            y: 0.0,
82            width: 100.0,
83            height: 100.0,
84        }
85    }
86
87    fn span(min: f64, max: f64) -> AxisSpan {
88        AxisSpan {
89            min,
90            max,
91            pmin: 0.0,
92            pmax: 1.0,
93        }
94    }
95
96    #[test]
97    fn log_warps_axis_nonlinearly() {
98        let mut c = CoordTrans::new(None, Some(ScaleTransform::Log10));
99        c.set_domains(None, Some(span(1.0, 100.0)));
100        // 10 is the geometric midpoint of [1, 100] → mid height on a log axis.
101        // Linear n for v=10 is (10-1)/99 ≈ 0.0909; the warp pushes it to 0.5.
102        let n10 = (10.0 - 1.0) / 99.0;
103        let (_, py) = c.transform((0.0, n10), &area());
104        assert!((py - 50.0).abs() < 1.0, "py = {py}, expected ~50");
105    }
106
107    #[test]
108    fn falls_back_to_linear_without_span_or_invalid() {
109        // No span set → linear passthrough.
110        let c = CoordTrans::new(None, Some(ScaleTransform::Log10));
111        let (_, py) = c.transform((0.0, 0.25), &area());
112        assert!((py - 75.0).abs() < 1e-9); // (1 - 0.25)*100
113
114        // Non-positive domain for log → linear fallback (no NaN/inf).
115        let mut c2 = CoordTrans::new(Some(ScaleTransform::Log10), None);
116        c2.set_domains(Some(span(-5.0, 5.0)), None);
117        let (px, _) = c2.transform((0.5, 0.0), &area());
118        assert!(px.is_finite());
119    }
120}