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
//! Monochrome SVG fill patterns — the hatching vocabulary of a 1970s
//! technical report.
//!
//! Each preset expands to a deterministic `<pattern>` element id. Solid
//! presets resolve to direct color fills; the rest emit `<pattern>`
//! defs that the renderer collects once per figure.

use crate::svg;

/// Hatch pattern preset.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Hatch {
    SolidWhite,
    SolidBlack,
    Diagonal,
    DiagonalDense,
    AntiDiagonal,
    Horizontal,
    Vertical,
    Crosshatch,
    Grid,
    DotsSparse,
    DotsDense,
    Brick,
    Zigzag,
    DashedH,
    Checker,
}

impl Hatch {
    /// Cycle order optimized for visual separation between adjacent
    /// series. Walked when `Hatch` is not explicitly set.
    pub const CYCLE: [Hatch; 15] = [
        Hatch::SolidWhite,
        Hatch::Diagonal,
        Hatch::AntiDiagonal,
        Hatch::Horizontal,
        Hatch::Vertical,
        Hatch::Crosshatch,
        Hatch::DotsSparse,
        Hatch::Grid,
        Hatch::Brick,
        Hatch::DotsDense,
        Hatch::Zigzag,
        Hatch::DashedH,
        Hatch::DiagonalDense,
        Hatch::Checker,
        Hatch::SolidBlack,
    ];

    pub fn cycle(index: usize) -> Hatch {
        Self::CYCLE[index % Self::CYCLE.len()]
    }

    /// Default light-to-dark heatmap ramp.
    pub const DEFAULT_RAMP: [Hatch; 7] = [
        Hatch::SolidWhite,
        Hatch::DotsSparse,
        Hatch::Diagonal,
        Hatch::Crosshatch,
        Hatch::DiagonalDense,
        Hatch::DotsDense,
        Hatch::SolidBlack,
    ];

    /// SVG `fill` attribute value (a color name or `url(#…)`).
    pub fn fill_value(self) -> String {
        match self {
            Hatch::SolidWhite => "white".to_string(),
            Hatch::SolidBlack => "black".to_string(),
            other => format!("url(#{})", other.dom_id()),
        }
    }

    /// Stable DOM id for the pattern definition.
    pub fn dom_id(self) -> &'static str {
        match self {
            Hatch::SolidWhite => "bland-pattern-solid-white",
            Hatch::SolidBlack => "bland-pattern-solid-black",
            Hatch::Diagonal => "bland-pattern-diagonal",
            Hatch::DiagonalDense => "bland-pattern-diagonal-dense",
            Hatch::AntiDiagonal => "bland-pattern-anti-diagonal",
            Hatch::Horizontal => "bland-pattern-horizontal",
            Hatch::Vertical => "bland-pattern-vertical",
            Hatch::Crosshatch => "bland-pattern-crosshatch",
            Hatch::Grid => "bland-pattern-grid",
            Hatch::DotsSparse => "bland-pattern-dots-sparse",
            Hatch::DotsDense => "bland-pattern-dots-dense",
            Hatch::Brick => "bland-pattern-brick",
            Hatch::Zigzag => "bland-pattern-zigzag",
            Hatch::DashedH => "bland-pattern-dashed-h",
            Hatch::Checker => "bland-pattern-checker",
        }
    }

    pub fn needs_def(self) -> bool {
        !matches!(self, Hatch::SolidWhite | Hatch::SolidBlack)
    }
}

/// Append `<pattern>` defs for each hatch in `used`. Solid presets emit
/// nothing — they resolve to color fills directly.
pub fn write_defs(buf: &mut String, used: &[Hatch]) {
    let mut seen = [false; 15];
    for hatch in used.iter().copied() {
        if !hatch.needs_def() {
            continue;
        }
        let idx = hatch as usize;
        if seen[idx] {
            continue;
        }
        seen[idx] = true;
        write_def(buf, hatch);
    }
}

fn write_def(buf: &mut String, hatch: Hatch) {
    let id = hatch.dom_id();
    match hatch {
        Hatch::Diagonal => pattern(buf, id, 8.0, 8.0, Some(45.0), &|b| line(b, 0, 0, 0, 8)),
        Hatch::DiagonalDense => {
            pattern(buf, id, 4.0, 4.0, Some(45.0), &|b| line(b, 0, 0, 0, 4))
        }
        Hatch::AntiDiagonal => pattern(buf, id, 8.0, 8.0, Some(-45.0), &|b| line(b, 0, 0, 0, 8)),
        Hatch::Horizontal => pattern(buf, id, 6.0, 6.0, None, &|b| line(b, 0, 3, 6, 3)),
        Hatch::Vertical => pattern(buf, id, 6.0, 6.0, None, &|b| line(b, 3, 0, 3, 6)),
        Hatch::Crosshatch => pattern(buf, id, 10.0, 10.0, None, &|b| {
            line(b, 0, 0, 10, 10);
            line(b, 10, 0, 0, 10);
        }),
        Hatch::Grid => pattern(buf, id, 8.0, 8.0, None, &|b| {
            line(b, 0, 0, 8, 0);
            line(b, 0, 0, 0, 8);
        }),
        Hatch::DotsSparse => pattern(buf, id, 10.0, 10.0, None, &|b| dot(b, 5.0, 5.0, 1.2)),
        Hatch::DotsDense => pattern(buf, id, 5.0, 5.0, None, &|b| dot(b, 2.5, 2.5, 1.0)),
        Hatch::Brick => pattern(buf, id, 16.0, 8.0, None, &|b| {
            line(b, 0, 8, 16, 8);
            line(b, 0, 4, 0, 8);
            line(b, 16, 4, 16, 8);
            line(b, 0, 0, 16, 0);
            line(b, 0, 4, 16, 4);
            line(b, 8, 0, 8, 4);
        }),
        Hatch::Zigzag => pattern(buf, id, 12.0, 8.0, None, &|b| {
            b.push_str(
                "<path d=\"M0 6 L3 2 L6 6 L9 2 L12 6\" fill=\"none\" stroke=\"black\" stroke-width=\"1\"/>",
            );
        }),
        Hatch::DashedH => pattern(buf, id, 10.0, 6.0, None, &|b| line(b, 0, 3, 5, 3)),
        Hatch::Checker => pattern(buf, id, 8.0, 8.0, None, &|b| {
            b.push_str("<rect x=\"0\" y=\"0\" width=\"4\" height=\"4\" fill=\"black\"/>");
            b.push_str("<rect x=\"4\" y=\"4\" width=\"4\" height=\"4\" fill=\"black\"/>");
        }),
        Hatch::SolidWhite | Hatch::SolidBlack => {}
    }
}

fn pattern(buf: &mut String, id: &str, w: f64, h: f64, rotate: Option<f64>, body: &dyn Fn(&mut String)) {
    buf.push_str("<pattern id=\"");
    buf.push_str(id);
    buf.push_str("\" patternUnits=\"userSpaceOnUse\" width=\"");
    svg::num_into(buf, w);
    buf.push_str("\" height=\"");
    svg::num_into(buf, h);
    buf.push('"');
    if let Some(deg) = rotate {
        buf.push_str(" patternTransform=\"rotate(");
        svg::num_into(buf, deg);
        buf.push_str(")\"");
    }
    buf.push('>');
    body(buf);
    buf.push_str("</pattern>");
}

fn line(buf: &mut String, x1: i32, y1: i32, x2: i32, y2: i32) {
    use std::fmt::Write;
    let _ = write!(
        buf,
        "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"black\" stroke-width=\"1\"/>",
        x1, y1, x2, y2
    );
}

fn dot(buf: &mut String, cx: f64, cy: f64, r: f64) {
    buf.push_str("<circle cx=\"");
    svg::num_into(buf, cx);
    buf.push_str("\" cy=\"");
    svg::num_into(buf, cy);
    buf.push_str("\" r=\"");
    svg::num_into(buf, r);
    buf.push_str("\" fill=\"black\"/>");
}

/// Heatmap quantization: map `value` in `[lo, hi]` to a level in
/// `[0, n_levels)`. Values outside the range clamp to the endpoints.
pub fn quantize(value: f64, lo: f64, hi: f64, n_levels: usize) -> usize {
    if lo == hi {
        return n_levels.saturating_sub(1) / 2;
    }
    if value <= lo {
        return 0;
    }
    if value >= hi {
        return n_levels - 1;
    }
    let scaled = ((value - lo) / (hi - lo) * n_levels as f64) as usize;
    scaled.min(n_levels - 1)
}

/// `(min, max)` of a 2D grid of numbers. Empty grids return `(0, 1)`.
pub fn extent(grid: &[Vec<f64>]) -> (f64, f64) {
    let mut iter = grid.iter().flat_map(|row| row.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;
        }
    }
    (lo, hi)
}

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

    #[test]
    fn quantize_clamps() {
        assert_eq!(quantize(-1.0, 0.0, 1.0, 4), 0);
        assert_eq!(quantize(2.0, 0.0, 1.0, 4), 3);
    }

    #[test]
    fn quantize_midpoint_is_in_middle() {
        assert_eq!(quantize(0.5, 0.0, 1.0, 4), 2);
    }

    #[test]
    fn extent_finds_min_max() {
        let grid = vec![vec![1.0, 4.0], vec![-1.0, 7.0]];
        assert_eq!(extent(&grid), (-1.0, 7.0));
    }
}