unicode-plot 0.1.0

unicode-plot-rs: Unicode terminal plotting library for Rust
Documentation
//! Shared floating-point math utilities for axis computation and data range
//! extension.

use std::cmp::Ordering;

/// Returns the minimum and maximum values from a slice.
///
/// For empty slices, returns `(f64::INFINITY, f64::NEG_INFINITY)`.
pub(crate) fn minmax(values: &[f64]) -> (f64, f64) {
    let mut min_value = f64::INFINITY;
    let mut max_value = f64::NEG_INFINITY;
    for value in values {
        min_value = min_value.min(*value);
        max_value = max_value.max(*value);
    }
    (min_value, max_value)
}

/// Exact bitwise equality via `total_cmp`.
pub(crate) fn same_value(left: f64, right: f64) -> bool {
    left.total_cmp(&right) == Ordering::Equal
}

/// Returns `true` when a float represents an exact integer that fits in `i64`.
pub(crate) fn roundable(value: f64) -> bool {
    value.is_finite()
        && value.fract().total_cmp(&0.0) == Ordering::Equal
        && format!("{value:.0}").parse::<i64>().is_ok()
}

/// Formats an axis boundary value, rounding to integer display when appropriate.
pub(crate) fn format_axis_value(value: f64) -> String {
    if roundable(value) {
        format!("{:.0}", value.round())
    } else {
        value.to_string()
    }
}

/// Rounds a positive value away from zero at the given decimal precision.
pub(crate) fn round_away_from_zero(value: f64, digits: i32) -> f64 {
    let factor = 10f64.powi(digits);
    (value * factor).ceil() / factor
}

/// Rounds a positive value toward zero at the given decimal precision.
pub(crate) fn round_toward_zero(value: f64, digits: i32) -> f64 {
    let factor = 10f64.powi(digits);
    (value * factor).floor() / factor
}

/// Computes the ceiling of `-log10(value)` using an iterative scaling loop.
///
/// This is equivalent to the number of decimal places needed to represent
/// `value` with at least one significant digit. The iterative approach avoids
/// floating-point edge cases that arise from `f64::log10` for non-power-of-10
/// inputs.
///
/// # Panics
///
/// Debug-asserts that `value` is positive. Passing zero or a negative value
/// would cause an infinite loop in release builds.
pub(crate) fn ceil_neg_log10(value: f64) -> i32 {
    debug_assert!(
        value > 0.0,
        "ceil_neg_log10 requires a positive value, got {value}"
    );
    let mut scaled = value;
    let mut digits = 0i32;

    while scaled < 1.0 {
        scaled *= 10.0;
        digits = digits.saturating_add(1);
    }
    while scaled >= 10.0 {
        scaled /= 10.0;
        digits = digits.saturating_sub(1);
    }

    digits
}

/// Extends axis limits by rounding outward from the data range.
pub(crate) fn plotting_range_narrow(min_value: f64, max_value: f64) -> (f64, f64) {
    let diff = max_value - min_value;
    (
        round_down_subtick(min_value, diff),
        round_up_subtick(max_value, diff),
    )
}

/// Rounds a maximum axis value upward by one sub-tick unit.
pub(crate) fn round_up_subtick(value: f64, magnitude: f64) -> f64 {
    if same_value(value, 0.0) {
        return 0.0;
    }

    let digits = ceil_neg_log10(magnitude) + 1;
    if value > 0.0 {
        round_away_from_zero(value, digits)
    } else {
        -round_toward_zero(-value, digits)
    }
}

/// Rounds a minimum axis value downward by one sub-tick unit.
pub(crate) fn round_down_subtick(value: f64, magnitude: f64) -> f64 {
    if same_value(value, 0.0) {
        return 0.0;
    }

    let digits = ceil_neg_log10(magnitude) + 1;
    if value > 0.0 {
        round_toward_zero(value, digits)
    } else {
        -round_away_from_zero(-value, digits)
    }
}

/// Computes data-aware axis limits with optional explicit overrides.
///
/// When both limit components are zero, auto-detects from data and narrows
/// via `plotting_range_narrow`. When limits are explicitly provided (non-zero),
/// returns them directly after ensuring min <= max.
pub(crate) fn extend_limits(values: &[f64], limits: (f64, f64)) -> (f64, f64) {
    let mut min_value = limits.0.min(limits.1);
    let mut max_value = limits.0.max(limits.1);

    if same_value(min_value, 0.0) && same_value(max_value, 0.0) {
        let (data_min, data_max) = minmax(values);
        min_value = data_min;
        max_value = data_max;
    }

    if same_value(min_value, max_value) {
        min_value -= 1.0;
        max_value += 1.0;
    }

    if same_value(limits.0, 0.0) && same_value(limits.1, 0.0) {
        plotting_range_narrow(min_value, max_value)
    } else {
        (min_value, max_value)
    }
}

/// Converts `usize` to `f64` losslessly for values up to `u32::MAX`,
/// clamping larger values.
pub(crate) fn usize_to_f64(value: usize) -> f64 {
    u32::try_from(value)
        .map(f64::from)
        .unwrap_or(f64::from(u32::MAX))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn minmax_basic() {
        assert_eq!(minmax(&[3.0, 1.0, 4.0, 1.5, 9.0]), (1.0, 9.0));
        assert_eq!(minmax(&[-5.0, 0.0, 5.0]), (-5.0, 5.0));
    }

    #[test]
    fn minmax_empty() {
        let (min, max) = minmax(&[]);
        assert!(min.is_infinite() && min.is_sign_positive());
        assert!(max.is_infinite() && max.is_sign_negative());
    }

    #[test]
    fn same_value_positive_and_negative_zero() {
        assert!(same_value(1.0, 1.0));
        assert!(!same_value(0.0, -0.0));
        assert!(!same_value(1.0, 1.0 + f64::EPSILON));
    }

    #[test]
    fn roundable_integers_and_non_integers() {
        assert!(roundable(5.0));
        assert!(roundable(-3.0));
        assert!(roundable(0.0));
        assert!(!roundable(1.5));
        assert!(!roundable(f64::NAN));
        assert!(!roundable(f64::INFINITY));
    }

    #[test]
    fn format_axis_value_integer_and_float() {
        assert_eq!(format_axis_value(5.0), "5");
        assert_eq!(format_axis_value(-3.0), "-3");
        assert_eq!(format_axis_value(1.5), "1.5");
    }

    #[test]
    fn round_away_and_toward_zero() {
        assert!(same_value(round_away_from_zero(1.23, 1), 1.3));
        assert!(same_value(round_toward_zero(1.23, 1), 1.2));
        assert!(same_value(round_away_from_zero(1.0, 0), 1.0));
        assert!(same_value(round_toward_zero(1.0, 0), 1.0));
    }

    #[test]
    fn ceil_neg_log10_powers_of_ten() {
        assert_eq!(ceil_neg_log10(0.001), 3);
        assert_eq!(ceil_neg_log10(0.01), 2);
        assert_eq!(ceil_neg_log10(0.1), 1);
        assert_eq!(ceil_neg_log10(1.0), 0);
        assert_eq!(ceil_neg_log10(10.0), -1);
        assert_eq!(ceil_neg_log10(100.0), -2);
    }

    #[test]
    fn ceil_neg_log10_non_powers() {
        assert_eq!(ceil_neg_log10(0.5), 1);
        assert_eq!(ceil_neg_log10(3.0), 0);
        assert_eq!(ceil_neg_log10(7.0), 0);
        assert_eq!(ceil_neg_log10(50.0), -1);
    }

    #[test]
    fn extend_limits_auto_and_explicit() {
        let (lo, hi) = extend_limits(&[1.0, 5.0], (0.0, 0.0));
        assert!(lo <= 1.0);
        assert!(hi >= 5.0);

        let (lo, hi) = extend_limits(&[1.0, 5.0], (2.0, 8.0));
        assert!(same_value(lo, 2.0));
        assert!(same_value(hi, 8.0));
    }

    #[test]
    fn extend_limits_single_value_expands() {
        let (lo, hi) = extend_limits(&[3.0], (0.0, 0.0));
        assert!(lo < 3.0);
        assert!(hi > 3.0);
    }

    #[test]
    fn round_up_subtick_positive_negative_and_zero() {
        // Zero short-circuits to zero.
        assert!(same_value(round_up_subtick(0.0, 1.0), 0.0));

        // Positive value rounds away from zero (upward).
        let result = round_up_subtick(2985.0, 8000.0);
        assert!(
            result >= 2985.0,
            "round_up_subtick should round upward: {result}"
        );

        // Negative value rounds toward zero (upward for negatives).
        let result = round_up_subtick(-5015.0, 8000.0);
        assert!(
            result >= -5015.0,
            "round_up_subtick on negative should round toward zero: {result}"
        );
    }

    #[test]
    fn round_down_subtick_positive_negative_and_zero() {
        // Zero short-circuits to zero.
        assert!(same_value(round_down_subtick(0.0, 1.0), 0.0));

        // Positive value rounds toward zero (downward).
        let result = round_down_subtick(2985.0, 8000.0);
        assert!(
            result <= 2985.0,
            "round_down_subtick should round downward: {result}"
        );

        // Negative value rounds away from zero (downward for negatives).
        let result = round_down_subtick(-5015.0, 8000.0);
        assert!(
            result <= -5015.0,
            "round_down_subtick on negative should round away from zero: {result}"
        );
    }

    #[test]
    fn plotting_range_narrow_expands_outward() {
        let (lo, hi) = plotting_range_narrow(1.0, 5.0);
        assert!(lo <= 1.0, "narrow lower bound should be <= min: {lo}");
        assert!(hi >= 5.0, "narrow upper bound should be >= max: {hi}");

        // Negative range.
        let (lo, hi) = plotting_range_narrow(-10.0, -5.0);
        assert!(lo <= -10.0, "narrow lower bound for negative range: {lo}");
        assert!(hi >= -5.0, "narrow upper bound for negative range: {hi}");

        // Mixed range crossing zero.
        let (lo, hi) = plotting_range_narrow(-3.0, 7.0);
        assert!(lo <= -3.0, "narrow lower bound for mixed range: {lo}");
        assert!(hi >= 7.0, "narrow upper bound for mixed range: {hi}");
    }

    #[test]
    fn usize_to_f64_small_and_large() {
        assert!(same_value(usize_to_f64(0), 0.0));
        assert!(same_value(usize_to_f64(42), 42.0));
        assert!(same_value(usize_to_f64(usize::MAX), f64::from(u32::MAX)));
    }
}