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
//! Geographic projections for plotting longitude/latitude data.
//!
//! Enable on a figure via `Figure::projection(Projection::Mercator)` /
//! `Projection::Equirect`. Each series is then interpreted with `xs` as
//! longitude (degrees) and `ys` as latitude (degrees), and the renderer
//! transforms each point through the named projection before applying
//! the Cartesian scales.
//!
//! BLAND ships no built-in coastline data — feed your own GeoJSON
//! (e.g. Natural Earth) via `Figure::polygon` / `Figure::line`.

const MERCATOR_MAX_LAT: f64 = 85.05112878;

/// Project `(lon, lat)` in degrees through Mercator. Latitudes are
/// clamped to ±85.051° to avoid infinity at the poles.
pub fn mercator(lon: f64, lat: f64) -> (f64, f64) {
    let lat_clamped = lat.clamp(-MERCATOR_MAX_LAT, MERCATOR_MAX_LAT);
    let x = lon * std::f64::consts::PI / 180.0;
    let y = (std::f64::consts::FRAC_PI_4 + lat_clamped * std::f64::consts::PI / 360.0)
        .tan()
        .ln();
    (x, y)
}

/// Equirectangular / plate carrée — degree-for-degree pass-through.
pub fn equirect(lon: f64, lat: f64) -> (f64, f64) {
    (lon, lat)
}

/// Generates graticule polylines (meridians + parallels) over the given
/// lon/lat range. Returns a vector of `(xs, ys)` tuples in degrees —
/// each is one full meridian or parallel ready to be added as a line
/// series.
pub fn graticule(opts: GraticuleOpts) -> Vec<(Vec<f64>, Vec<f64>)> {
    let mut out = Vec::new();
    for lon in arange(opts.lon_lo, opts.lon_hi, opts.lon_step) {
        let lats = linspace(opts.lat_lo, opts.lat_hi, opts.samples);
        let xs = vec![lon; lats.len()];
        out.push((xs, lats));
    }
    for lat in arange(opts.lat_lo, opts.lat_hi, opts.lat_step) {
        let lons = linspace(opts.lon_lo, opts.lon_hi, opts.samples);
        let ys = vec![lat; lons.len()];
        out.push((lons, ys));
    }
    out
}

/// Configuration for [`graticule`].
#[derive(Debug, Clone, Copy)]
pub struct GraticuleOpts {
    pub lon_step: f64,
    pub lat_step: f64,
    pub lon_lo: f64,
    pub lon_hi: f64,
    pub lat_lo: f64,
    pub lat_hi: f64,
    pub samples: usize,
}

impl Default for GraticuleOpts {
    fn default() -> Self {
        Self {
            lon_step: 30.0,
            lat_step: 30.0,
            lon_lo: -180.0,
            lon_hi: 180.0,
            lat_lo: -80.0,
            lat_hi: 80.0,
            samples: 60,
        }
    }
}

fn arange(lo: f64, hi: f64, step: f64) -> Vec<f64> {
    if step <= 0.0 {
        return Vec::new();
    }
    let n = ((hi - lo) / step).floor() as usize;
    (0..=n).map(|i| lo + i as f64 * step).collect()
}

fn linspace(a: f64, b: f64, n: usize) -> Vec<f64> {
    if n < 2 {
        return vec![a];
    }
    let step = (b - a) / (n as f64 - 1.0);
    (0..n).map(|i| a + i as f64 * step).collect()
}

/// Format a longitude value with E/W suffix.
pub fn format_lon(v: f64) -> String {
    let v_int = if v == v.trunc() { v as i32 } else { 0 };
    if v == 0.0 {
        return "".to_string();
    }
    if v > 0.0 && v <= 180.0 {
        return format!("{}°E", fmt_deg(v, v_int));
    }
    if v < 0.0 && v >= -180.0 {
        return format!("{}°W", fmt_deg(-v, -v_int));
    }
    if v > 180.0 {
        return format!("{}°W", fmt_deg(360.0 - v, (360.0 - v) as i32));
    }
    format!("{}°E", fmt_deg(v + 360.0, (v + 360.0) as i32))
}

/// Format a latitude value with N/S suffix.
pub fn format_lat(v: f64) -> String {
    let v_int = if v == v.trunc() { v as i32 } else { 0 };
    if v == 0.0 {
        "".to_string()
    } else if v > 0.0 {
        format!("{}°N", fmt_deg(v, v_int))
    } else {
        format!("{}°S", fmt_deg(-v, -v_int))
    }
}

fn fmt_deg(v: f64, v_int: i32) -> String {
    if v == v.trunc() {
        v_int.to_string()
    } else {
        format!("{:.1}", v)
    }
}

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

    #[test]
    fn mercator_origin_is_origin() {
        let (x, y) = mercator(0.0, 0.0);
        assert!(x.abs() < 1e-9);
        assert!(y.abs() < 1e-9);
    }

    #[test]
    fn mercator_45_north() {
        let (_, y) = mercator(0.0, 45.0);
        assert!((y - 0.881_374).abs() < 1e-4);
    }

    #[test]
    fn equirect_is_identity() {
        assert_eq!(equirect(10.0, 20.0), (10.0, 20.0));
    }
}