1#[derive(Debug, Clone, Copy, PartialEq)]
3#[non_exhaustive]
4pub enum Scale {
5 Identity,
7 Ln,
9 Log2,
11 Log10,
13}
14
15impl Scale {
16 #[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#[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 #[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 #[must_use]
65 pub const fn origin(self) -> f64 {
66 self.origin
67 }
68
69 #[must_use]
71 pub const fn span(self) -> f64 {
72 self.span
73 }
74
75 #[must_use]
77 pub const fn pixels(self) -> usize {
78 self.pixels
79 }
80
81 #[must_use]
83 pub const fn scale(self) -> Scale {
84 self.scale
85 }
86
87 #[must_use]
89 pub const fn flip(self) -> bool {
90 self.flip
91 }
92
93 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Transform2D {
147 x: AxisTransform,
148 y: AxisTransform,
149}
150
151impl Transform2D {
152 #[must_use]
154 pub const fn new(x: AxisTransform, y: AxisTransform) -> Self {
155 Self { x, y }
156 }
157
158 #[must_use]
160 pub const fn x(self) -> AxisTransform {
161 self.x
162 }
163
164 #[must_use]
166 pub const fn y(self) -> AxisTransform {
167 self.y
168 }
169
170 #[must_use]
172 pub fn data_to_pixel_x(self, x: f64) -> Option<i32> {
173 self.x.data_to_pixel(x)
174 }
175
176 #[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}