unicode-plot 0.1.0

unicode-plot-rs: Unicode terminal plotting library for Rust
Documentation
/// Axis scaling transform applied before coordinate mapping.
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Scale {
    /// No transform; values pass through unchanged.
    Identity,
    /// Natural logarithm.
    Ln,
    /// Base-2 logarithm.
    Log2,
    /// Base-10 logarithm.
    Log10,
}

impl Scale {
    /// Applies this scale to `value`, returning the transformed result.
    ///
    /// Log scales follow IEEE 754 semantics: `log(0)` is negative infinity,
    /// `log(negative)` is `NaN`.
    #[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(),
        }
    }
}

/// Maps data-space coordinates along one axis to pixel positions.
///
/// An axis transform stores origin, span, pixel count, optional log scale,
/// and optional flip (used for the y-axis so that higher values appear at the
/// top of the terminal).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AxisTransform {
    origin: f64,
    span: f64,
    pixels: usize,
    scale: Scale,
    flip: bool,
}

impl AxisTransform {
    /// Creates a new axis transform. Returns `None` when `origin` or `span`
    /// is non-finite, `span` is zero, or `pixels` is zero.
    #[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,
        })
    }

    /// The origin of the data range on this axis.
    #[must_use]
    pub const fn origin(self) -> f64 {
        self.origin
    }

    /// The data-space span along this axis.
    #[must_use]
    pub const fn span(self) -> f64 {
        self.span
    }

    /// The number of pixels along this axis.
    #[must_use]
    pub const fn pixels(self) -> usize {
        self.pixels
    }

    /// The scale applied before coordinate mapping.
    #[must_use]
    pub const fn scale(self) -> Scale {
        self.scale
    }

    /// Whether this axis is flipped (y-axis: higher values at top).
    #[must_use]
    pub const fn flip(self) -> bool {
        self.flip
    }

    /// Converts a data-space value to a pixel coordinate.
    ///
    /// Returns `None` for non-finite values, values outside the log domain
    /// (zero or negative with a log scale), or results that overflow `i32`.
    #[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;
        }

        // SAFETY/JUSTIFICATION: the range check above guarantees `floored` fits in i32.
        #[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)
    }
}

/// Combined x and y axis transforms for a 2D plotting area.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transform2D {
    x: AxisTransform,
    y: AxisTransform,
}

impl Transform2D {
    /// Creates a 2D transform from separate x and y axis transforms.
    #[must_use]
    pub const fn new(x: AxisTransform, y: AxisTransform) -> Self {
        Self { x, y }
    }

    /// Returns the x-axis transform.
    #[must_use]
    pub const fn x(self) -> AxisTransform {
        self.x
    }

    /// Returns the y-axis transform.
    #[must_use]
    pub const fn y(self) -> AxisTransform {
        self.y
    }

    /// Converts a data-space x value to a pixel x coordinate.
    #[must_use]
    pub fn data_to_pixel_x(self, x: f64) -> Option<i32> {
        self.x.data_to_pixel(x)
    }

    /// Converts a data-space y value to a pixel y coordinate.
    #[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);
    }
}