bland 0.2.0

Pure-Rust library for paper-ready, monochrome, hatch-patterned technical plots in the visual tradition of 1960s-80s engineering reports.
Documentation
//! Coordinate scaling between *data space* and *canvas space*.

#[derive(Debug, Clone, Copy)]
pub enum ScaleKind {
    Linear,
    Log,
}

#[derive(Debug, Clone, Copy)]
pub struct Scale {
    pub kind: ScaleKind,
    pub d0: f64,
    pub d1: f64,
    pub r0: f64,
    pub r1: f64,
    pub base: f64,
}

impl Scale {
    pub fn linear(domain: (f64, f64), range: (f64, f64)) -> Self {
        Self {
            kind: ScaleKind::Linear,
            d0: domain.0,
            d1: domain.1,
            r0: range.0,
            r1: range.1,
            base: 10.0,
        }
    }

    pub fn log(domain: (f64, f64), range: (f64, f64), base: f64) -> Self {
        Self {
            kind: ScaleKind::Log,
            d0: domain.0,
            d1: domain.1,
            r0: range.0,
            r1: range.1,
            base,
        }
    }

    pub fn project(&self, v: f64) -> f64 {
        match self.kind {
            ScaleKind::Linear => self.r0 + (v - self.d0) / (self.d1 - self.d0) * (self.r1 - self.r0),
            ScaleKind::Log => {
                let lb = self.base.ln();
                let lv = v.ln() / lb;
                let l0 = self.d0.ln() / lb;
                let l1 = self.d1.ln() / lb;
                self.r0 + (lv - l0) / (l1 - l0) * (self.r1 - self.r0)
            }
        }
    }

    pub fn domain(&self) -> (f64, f64) {
        (self.d0, self.d1)
    }
}

/// Pad a `[lo, hi]` interval by `padding * span` on each side. A
/// zero-span input gets `±1` padding so the resulting domain never
/// collapses to a point.
pub fn auto_domain(values: &[f64], padding: f64) -> (f64, f64) {
    let mut iter = values.iter().copied().filter(|v| v.is_finite());
    let first = match iter.next() {
        Some(v) => v,
        None => return (0.0, 1.0),
    };
    let mut lo = first;
    let mut hi = first;
    for v in iter {
        if v < lo {
            lo = v;
        }
        if v > hi {
            hi = v;
        }
    }
    if lo == hi {
        if lo == 0.0 {
            return (-1.0, 1.0);
        }
        let pad = lo.abs() * 0.1;
        return (lo - pad, hi + pad);
    }
    let pad = (hi - lo) * padding;
    (lo - pad, hi + pad)
}

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

    #[test]
    fn linear_projection_is_proportional() {
        let s = Scale::linear((0.0, 10.0), (40.0, 440.0));
        assert_eq!(s.project(0.0), 40.0);
        assert_eq!(s.project(5.0), 240.0);
        assert_eq!(s.project(10.0), 440.0);
    }

    #[test]
    fn log_projection_is_proportional_in_log_space() {
        let s = Scale::log((1.0, 100.0), (0.0, 200.0), 10.0);
        assert!((s.project(10.0) - 100.0).abs() < 1e-9);
    }

    #[test]
    fn zero_span_gets_unit_pad() {
        assert_eq!(auto_domain(&[0.0, 0.0, 0.0], 0.05), (-1.0, 1.0));
    }
}