#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Scale {
Identity,
Ln,
Log2,
Log10,
}
impl Scale {
#[must_use]
pub fn apply(self, value: f64) -> f64 {
match self {
Self::Identity => value,
Self::Ln => value.ln(),
Self::Log2 => value.log2(),
Self::Log10 => value.log10(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AxisTransform {
origin: f64,
span: f64,
pixels: usize,
scale: Scale,
flip: bool,
}
impl AxisTransform {
#[must_use]
pub fn new(origin: f64, span: f64, pixels: usize, scale: Scale, flip: bool) -> Option<Self> {
if !origin.is_finite() || !span.is_finite() || span == 0.0 || pixels == 0 {
return None;
}
Some(Self {
origin,
span,
pixels,
scale,
flip,
})
}
#[must_use]
pub const fn origin(self) -> f64 {
self.origin
}
#[must_use]
pub const fn span(self) -> f64 {
self.span
}
#[must_use]
pub const fn pixels(self) -> usize {
self.pixels
}
#[must_use]
pub const fn scale(self) -> Scale {
self.scale
}
#[must_use]
pub const fn flip(self) -> bool {
self.flip
}
#[must_use]
pub fn data_to_pixel(self, value: f64) -> Option<i32> {
let scaled = self.apply_scale(value)?;
let normalized = (scaled - self.origin) / self.span;
let pixels = u32::try_from(self.pixels).ok()?;
let pixel_span = f64::from(pixels);
if !normalized.is_finite() {
return None;
}
let projected = if self.flip {
(1.0 - normalized) * pixel_span
} else {
normalized * pixel_span
};
if !projected.is_finite() {
return None;
}
let floored = projected.floor();
if !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&floored) {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let pixel = floored as i32;
Some(pixel)
}
fn apply_scale(self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if matches!(self.scale, Scale::Ln | Scale::Log2 | Scale::Log10) && value <= 0.0 {
return None;
}
let scaled = self.scale.apply(value);
scaled.is_finite().then_some(scaled)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transform2D {
x: AxisTransform,
y: AxisTransform,
}
impl Transform2D {
#[must_use]
pub const fn new(x: AxisTransform, y: AxisTransform) -> Self {
Self { x, y }
}
#[must_use]
pub const fn x(self) -> AxisTransform {
self.x
}
#[must_use]
pub const fn y(self) -> AxisTransform {
self.y
}
#[must_use]
pub fn data_to_pixel_x(self, x: f64) -> Option<i32> {
self.x.data_to_pixel(x)
}
#[must_use]
pub fn data_to_pixel_y(self, y: f64) -> Option<i32> {
self.y.data_to_pixel(y)
}
}
#[cfg(test)]
mod tests {
use super::{AxisTransform, Scale, Transform2D};
fn assert_close(actual: f64, expected: f64) {
let delta = (actual - expected).abs();
assert!(delta <= 1e-12, "actual={actual} expected={expected}");
}
#[test]
fn scale_apply_supports_identity_and_logs() {
assert_close(Scale::Identity.apply(9.5), 9.5);
assert_close(Scale::Ln.apply(std::f64::consts::E), 1.0);
assert_close(Scale::Log2.apply(8.0), 3.0);
assert_close(Scale::Log10.apply(1000.0), 3.0);
}
#[test]
fn scale_apply_log_edge_cases_follow_float_semantics() {
assert!(Scale::Ln.apply(0.0).is_infinite());
assert!(Scale::Log2.apply(0.0).is_infinite());
assert!(Scale::Log10.apply(0.0).is_infinite());
assert!(Scale::Ln.apply(-1.0).is_nan());
assert!(Scale::Log2.apply(-1.0).is_nan());
assert!(Scale::Log10.apply(-1.0).is_nan());
}
#[test]
fn transform_2d_maps_known_points() {
let x = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, false);
let y = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, true);
assert!(x.is_some() && y.is_some());
let transform = Transform2D::new(
x.unwrap_or_else(|| unreachable!("checked above")),
y.unwrap_or_else(|| unreachable!("checked above")),
);
assert_eq!(transform.data_to_pixel_x(0.0), Some(0));
assert_eq!(transform.data_to_pixel_x(5.0), Some(50));
assert_eq!(transform.data_to_pixel_x(10.0), Some(100));
assert_eq!(transform.data_to_pixel_y(0.0), Some(100));
assert_eq!(transform.data_to_pixel_y(5.0), Some(50));
assert_eq!(transform.data_to_pixel_y(10.0), Some(0));
}
#[test]
fn axis_transform_returns_none_for_invalid_inputs() {
let log_transform = AxisTransform::new(0.0, 10.0, 100, Scale::Log10, false)
.unwrap_or_else(|| unreachable!("valid transform"));
assert_eq!(log_transform.data_to_pixel(0.0), None);
assert_eq!(log_transform.data_to_pixel(-1.0), None);
assert_eq!(log_transform.data_to_pixel(f64::NAN), None);
assert_eq!(log_transform.data_to_pixel(f64::INFINITY), None);
}
#[test]
fn axis_transform_constructor_rejects_invalid_configuration() {
assert_eq!(
AxisTransform::new(0.0, 0.0, 100, Scale::Identity, false),
None
);
assert_eq!(
AxisTransform::new(0.0, 10.0, 0, Scale::Identity, false),
None
);
assert_eq!(
AxisTransform::new(f64::INFINITY, 10.0, 100, Scale::Identity, false),
None
);
}
#[test]
fn transform_2d_wrappers_reject_nan_and_infinity() {
let x = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, false)
.unwrap_or_else(|| unreachable!("valid transform"));
let y = AxisTransform::new(0.0, 10.0, 100, Scale::Identity, true)
.unwrap_or_else(|| unreachable!("valid transform"));
let transform = Transform2D::new(x, y);
assert_eq!(transform.data_to_pixel_x(f64::NAN), None);
assert_eq!(transform.data_to_pixel_x(f64::INFINITY), None);
assert_eq!(transform.data_to_pixel_y(f64::NAN), None);
assert_eq!(transform.data_to_pixel_y(f64::NEG_INFINITY), None);
}
}