bland 0.2.1

Pure-Rust library for paper-ready, monochrome, hatch-patterned technical plots in the visual tradition of 1960s-80s engineering reports.
Documentation
//! Nice-rounded tick generation.
//!
//! Picks evenly-spaced tick values within a domain using the classic
//! 1-2-2.5-5-10 decade decomposition so axes break on numbers a human
//! would pick by hand — the kind of axis you'd draw on engineering
//! graph paper.

const NICE_STEPS: [f64; 5] = [1.0, 2.0, 2.5, 5.0, 10.0];

/// Linear-axis ticks. `target` is the approximate number of ticks
/// desired; the actual count will be close but pinned to nice steps.
pub fn nice(domain: (f64, f64), target: usize) -> Vec<f64> {
    let (mut lo, mut hi) = domain;
    if lo > hi {
        std::mem::swap(&mut lo, &mut hi);
    }
    if lo == hi || target < 2 {
        return vec![lo];
    }
    let step = nice_step((hi - lo) / target as f64);
    let start = (lo / step).ceil() * step;
    let stop = (hi / step).floor() * step;
    let n = ((stop - start) / step).round() as i64;
    if n < 0 {
        return Vec::new();
    }
    let decimals = ((-floor_log10(step)).max(0.0) as i32 + 2) as i32;
    (0..=n)
        .map(|i| round_to_decimals(start + i as f64 * step, decimals))
        .collect()
}

/// Log-axis ticks at integer powers of `base`.
pub fn log_nice(domain: (f64, f64), base: f64) -> Vec<f64> {
    let (mut lo, mut hi) = domain;
    if lo > hi {
        std::mem::swap(&mut lo, &mut hi);
    }
    if lo <= 0.0 || hi <= 0.0 || lo == hi {
        return Vec::new();
    }
    let lb = base.ln();
    let lo_exp = (lo.ln() / lb).floor() as i32;
    let hi_exp = (hi.ln() / lb).ceil() as i32;
    (lo_exp..=hi_exp)
        .map(|e| base.powi(e))
        .filter(|v| *v >= lo && *v <= hi)
        .collect()
}

/// Default tick formatter. Integers print without a decimal; floats
/// trim trailing zeros and switch to `e` notation at very large or
/// very small magnitudes.
pub fn format(v: f64) -> String {
    if !v.is_finite() {
        return "".to_string();
    }
    if v == v.trunc() && v.abs() < 1e16 {
        return format!("{}", v as i64);
    }
    let abs = v.abs();
    if abs >= 1_000_000.0 || (abs < 0.001 && v != 0.0) {
        return format!("{:.2e}", v);
    }
    let s = format!("{:.3}", v);
    let trimmed = s.trim_end_matches('0').trim_end_matches('.');
    trimmed.to_string()
}

fn nice_step(raw: f64) -> f64 {
    if raw <= 0.0 {
        return 1.0;
    }
    let exp = raw.log10().floor();
    let pow = 10f64.powf(exp);
    let fraction = raw / pow;
    let step_frac = NICE_STEPS
        .iter()
        .copied()
        .find(|s| *s >= fraction)
        .unwrap_or(*NICE_STEPS.last().unwrap());
    step_frac * pow
}

fn floor_log10(x: f64) -> f64 {
    if x > 0.0 {
        x.log10().floor()
    } else {
        0.0
    }
}

fn round_to_decimals(value: f64, decimals: i32) -> f64 {
    let pow = 10f64.powi(decimals);
    (value * pow).round() / pow
}

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

    #[test]
    fn nice_picks_round_steps() {
        let ticks = nice((0.0, 9.7), 5);
        assert_eq!(&ticks[..3], &[0.0, 2.0, 4.0]);
    }

    #[test]
    fn log_picks_powers_of_ten() {
        assert_eq!(log_nice((0.5, 250.0), 10.0), vec![1.0, 10.0, 100.0]);
    }

    #[test]
    fn format_strips_trailing_zeros() {
        assert_eq!(format(1.5), "1.5");
        assert_eq!(format(2.0), "2");
    }
}