Skip to main content

unicode_plot/canvas/
transform.rs

1/// Axis scaling transform applied before coordinate mapping.
2#[derive(Debug, Clone, Copy, PartialEq)]
3#[non_exhaustive]
4pub enum Scale {
5    /// No transform; values pass through unchanged.
6    Identity,
7    /// Natural logarithm.
8    Ln,
9    /// Base-2 logarithm.
10    Log2,
11    /// Base-10 logarithm.
12    Log10,
13}
14
15impl Scale {
16    /// Applies this scale to `value`, returning the transformed result.
17    ///
18    /// Log scales follow IEEE 754 semantics: `log(0)` is negative infinity,
19    /// `log(negative)` is `NaN`.
20    #[must_use]
21    pub fn apply(self, value: f64) -> f64 {
22        match self {
23            Self::Identity => value,
24            Self::Ln => value.ln(),
25            Self::Log2 => value.log2(),
26            Self::Log10 => value.log10(),
27        }
28    }
29}
30
31/// Maps data-space coordinates along one axis to pixel positions.
32///
33/// An axis transform stores origin, span, pixel count, optional log scale,
34/// and optional flip (used for the y-axis so that higher values appear at the
35/// top of the terminal).
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct AxisTransform {
38    origin: f64,
39    span: f64,
40    pixels: usize,
41    scale: Scale,
42    flip: bool,
43}
44
45impl AxisTransform {
46    /// Creates a new axis transform. Returns `None` when `origin` or `span`
47    /// is non-finite, `span` is zero, or `pixels` is zero.
48    #[must_use]
49    pub fn new(origin: f64, span: f64, pixels: usize, scale: Scale, flip: bool) -> Option<Self> {
50        if !origin.is_finite() || !span.is_finite() || span == 0.0 || pixels == 0 {
51            return None;
52        }
53
54        Some(Self {
55            origin,
56            span,
57            pixels,
58            scale,
59            flip,
60        })
61    }
62
63    /// The origin of the data range on this axis.
64    #[must_use]
65    pub const fn origin(self) -> f64 {
66        self.origin
67    }
68
69    /// The data-space span along this axis.
70    #[must_use]
71    pub const fn span(self) -> f64 {
72        self.span
73    }
74
75    /// The number of pixels along this axis.
76    #[must_use]
77    pub const fn pixels(self) -> usize {
78        self.pixels
79    }
80
81    /// The scale applied before coordinate mapping.
82    #[must_use]
83    pub const fn scale(self) -> Scale {
84        self.scale
85    }
86
87    /// Whether this axis is flipped (y-axis: higher values at top).
88    #[must_use]
89    pub const fn flip(self) -> bool {
90        self.flip
91    }
92
93    /// Converts a data-space value to a pixel coordinate.
94    ///
95    /// Returns `None` for non-finite values, values outside the log domain
96    /// (zero or negative with a log scale), or results that overflow `i32`.
97    #[must_use]
98    pub fn data_to_pixel(self, value: f64) -> Option<i32> {
99        let scaled = self.apply_scale(value)?;
100        let normalized = (scaled - self.origin) / self.span;
101        let pixels = u32::try_from(self.pixels).ok()?;
102        let pixel_span = f64::from(pixels);
103
104        if !normalized.is_finite() {
105            return None;
106        }
107
108        let projected = if self.flip {
109            (1.0 - normalized) * pixel_span
110        } else {
111            normalized * pixel_span
112        };
113
114        if !projected.is_finite() {
115            return None;
116        }
117
118        let floored = projected.floor();
119        if !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&floored) {
120            return None;
121        }
122
123        // SAFETY/JUSTIFICATION: the range check above guarantees `floored` fits in i32.
124        #[allow(clippy::cast_possible_truncation)]
125        let pixel = floored as i32;
126
127        Some(pixel)
128    }
129
130    fn apply_scale(self, value: f64) -> Option<f64> {
131        if !value.is_finite() {
132            return None;
133        }
134
135        if matches!(self.scale, Scale::Ln | Scale::Log2 | Scale::Log10) && value <= 0.0 {
136            return None;
137        }
138
139        let scaled = self.scale.apply(value);
140        scaled.is_finite().then_some(scaled)
141    }
142}
143
144/// Combined x and y axis transforms for a 2D plotting area.
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Transform2D {
147    x: AxisTransform,
148    y: AxisTransform,
149}
150
151impl Transform2D {
152    /// Creates a 2D transform from separate x and y axis transforms.
153    #[must_use]
154    pub const fn new(x: AxisTransform, y: AxisTransform) -> Self {
155        Self { x, y }
156    }
157
158    /// Returns the x-axis transform.
159    #[must_use]
160    pub const fn x(self) -> AxisTransform {
161        self.x
162    }
163
164    /// Returns the y-axis transform.
165    #[must_use]
166    pub const fn y(self) -> AxisTransform {
167        self.y
168    }
169
170    /// Converts a data-space x value to a pixel x coordinate.
171    #[must_use]
172    pub fn data_to_pixel_x(self, x: f64) -> Option<i32> {
173        self.x.data_to_pixel(x)
174    }
175
176    /// Converts a data-space y value to a pixel y coordinate.
177    #[must_use]
178    pub fn data_to_pixel_y(self, y: f64) -> Option<i32> {
179        self.y.data_to_pixel(y)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::{AxisTransform, Scale, Transform2D};
186
187    fn assert_close(actual: f64, expected: f64) {
188        let delta = (actual - expected).abs();
189        assert!(delta <= 1e-12, "actual={actual} expected={expected}");
190    }
191
192    #[test]
193    fn scale_apply_supports_identity_and_logs() {
194        assert_close(Scale::Identity.apply(9.5), 9.5);
195        assert_close(Scale::Ln.apply(std::f64::consts::E), 1.0);
196        assert_close(Scale::Log2.apply(8.0), 3.0);
197        assert_close(Scale::Log10.apply(1000.0), 3.0);
198    }
199
200    #[test]
201    fn scale_apply_log_edge_cases_follow_float_semantics() {
202        assert!(Scale::Ln.apply(0.0).is_infinite());
203        assert!(Scale::Log2.apply(0.0).is_infinite());
204        assert!(Scale::Log10.apply(0.0).is_infinite());
205        assert!(Scale::Ln.apply(-1.0).is_nan());
206        assert!(Scale::Log2.apply(-1.0).is_nan());
207        assert!(Scale::Log10.apply(-1.0).is_nan());
208    }
209
210    #[test]
211    fn transform_2d_maps_known_points() {
212        let x = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, false);
213        let y = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, true);
214        assert!(x.is_some() && y.is_some());
215
216        let transform = Transform2D::new(
217            x.unwrap_or_else(|| unreachable!("checked above")),
218            y.unwrap_or_else(|| unreachable!("checked above")),
219        );
220
221        assert_eq!(transform.data_to_pixel_x(0.0), Some(0));
222        assert_eq!(transform.data_to_pixel_x(5.0), Some(50));
223        assert_eq!(transform.data_to_pixel_x(10.0), Some(100));
224        assert_eq!(transform.data_to_pixel_y(0.0), Some(100));
225        assert_eq!(transform.data_to_pixel_y(5.0), Some(50));
226        assert_eq!(transform.data_to_pixel_y(10.0), Some(0));
227    }
228
229    #[test]
230    fn axis_transform_returns_none_for_invalid_inputs() {
231        let log_transform = AxisTransform::new(0.0, 10.0, 100, Scale::Log10, false)
232            .unwrap_or_else(|| unreachable!("valid transform"));
233
234        assert_eq!(log_transform.data_to_pixel(0.0), None);
235        assert_eq!(log_transform.data_to_pixel(-1.0), None);
236        assert_eq!(log_transform.data_to_pixel(f64::NAN), None);
237        assert_eq!(log_transform.data_to_pixel(f64::INFINITY), None);
238    }
239
240    #[test]
241    fn axis_transform_constructor_rejects_invalid_configuration() {
242        assert_eq!(
243            AxisTransform::new(0.0, 0.0, 100, Scale::Identity, false),
244            None
245        );
246        assert_eq!(
247            AxisTransform::new(0.0, 10.0, 0, Scale::Identity, false),
248            None
249        );
250        assert_eq!(
251            AxisTransform::new(f64::INFINITY, 10.0, 100, Scale::Identity, false),
252            None
253        );
254    }
255
256    #[test]
257    fn transform_2d_wrappers_reject_nan_and_infinity() {
258        let x = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, false)
259            .unwrap_or_else(|| unreachable!("valid transform"));
260        let y = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, true)
261            .unwrap_or_else(|| unreachable!("valid transform"));
262        let transform = Transform2D::new(x, y);
263
264        assert_eq!(transform.data_to_pixel_x(f64::NAN), None);
265        assert_eq!(transform.data_to_pixel_x(f64::INFINITY), None);
266        assert_eq!(transform.data_to_pixel_y(f64::NAN), None);
267        assert_eq!(transform.data_to_pixel_y(f64::NEG_INFINITY), None);
268    }
269}