Skip to main content

bland/
hatch.rs

1//! Monochrome SVG fill patterns — the hatching vocabulary of a 1970s
2//! technical report.
3//!
4//! Each preset expands to a deterministic `<pattern>` element id. Solid
5//! presets resolve to direct color fills; the rest emit `<pattern>`
6//! defs that the renderer collects once per figure.
7
8use crate::svg;
9
10/// Hatch pattern preset.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum Hatch {
13    SolidWhite,
14    SolidBlack,
15    Diagonal,
16    DiagonalDense,
17    AntiDiagonal,
18    Horizontal,
19    Vertical,
20    Crosshatch,
21    Grid,
22    DotsSparse,
23    DotsDense,
24    Brick,
25    Zigzag,
26    DashedH,
27    Checker,
28}
29
30impl Hatch {
31    /// Cycle order optimized for visual separation between adjacent
32    /// series. Walked when `Hatch` is not explicitly set.
33    pub const CYCLE: [Hatch; 15] = [
34        Hatch::SolidWhite,
35        Hatch::Diagonal,
36        Hatch::AntiDiagonal,
37        Hatch::Horizontal,
38        Hatch::Vertical,
39        Hatch::Crosshatch,
40        Hatch::DotsSparse,
41        Hatch::Grid,
42        Hatch::Brick,
43        Hatch::DotsDense,
44        Hatch::Zigzag,
45        Hatch::DashedH,
46        Hatch::DiagonalDense,
47        Hatch::Checker,
48        Hatch::SolidBlack,
49    ];
50
51    pub fn cycle(index: usize) -> Hatch {
52        Self::CYCLE[index % Self::CYCLE.len()]
53    }
54
55    /// Default light-to-dark heatmap ramp.
56    pub const DEFAULT_RAMP: [Hatch; 7] = [
57        Hatch::SolidWhite,
58        Hatch::DotsSparse,
59        Hatch::Diagonal,
60        Hatch::Crosshatch,
61        Hatch::DiagonalDense,
62        Hatch::DotsDense,
63        Hatch::SolidBlack,
64    ];
65
66    /// SVG `fill` attribute value (a color name or `url(#…)`).
67    pub fn fill_value(self) -> String {
68        match self {
69            Hatch::SolidWhite => "white".to_string(),
70            Hatch::SolidBlack => "black".to_string(),
71            other => format!("url(#{})", other.dom_id()),
72        }
73    }
74
75    /// Stable DOM id for the pattern definition.
76    pub fn dom_id(self) -> &'static str {
77        match self {
78            Hatch::SolidWhite => "bland-pattern-solid-white",
79            Hatch::SolidBlack => "bland-pattern-solid-black",
80            Hatch::Diagonal => "bland-pattern-diagonal",
81            Hatch::DiagonalDense => "bland-pattern-diagonal-dense",
82            Hatch::AntiDiagonal => "bland-pattern-anti-diagonal",
83            Hatch::Horizontal => "bland-pattern-horizontal",
84            Hatch::Vertical => "bland-pattern-vertical",
85            Hatch::Crosshatch => "bland-pattern-crosshatch",
86            Hatch::Grid => "bland-pattern-grid",
87            Hatch::DotsSparse => "bland-pattern-dots-sparse",
88            Hatch::DotsDense => "bland-pattern-dots-dense",
89            Hatch::Brick => "bland-pattern-brick",
90            Hatch::Zigzag => "bland-pattern-zigzag",
91            Hatch::DashedH => "bland-pattern-dashed-h",
92            Hatch::Checker => "bland-pattern-checker",
93        }
94    }
95
96    pub fn needs_def(self) -> bool {
97        !matches!(self, Hatch::SolidWhite | Hatch::SolidBlack)
98    }
99}
100
101/// Append `<pattern>` defs for each hatch in `used`. Solid presets emit
102/// nothing — they resolve to color fills directly.
103pub fn write_defs(buf: &mut String, used: &[Hatch]) {
104    let mut seen = [false; 15];
105    for hatch in used.iter().copied() {
106        if !hatch.needs_def() {
107            continue;
108        }
109        let idx = hatch as usize;
110        if seen[idx] {
111            continue;
112        }
113        seen[idx] = true;
114        write_def(buf, hatch);
115    }
116}
117
118fn write_def(buf: &mut String, hatch: Hatch) {
119    let id = hatch.dom_id();
120    match hatch {
121        Hatch::Diagonal => pattern(buf, id, 8.0, 8.0, Some(45.0), &|b| line(b, 0, 0, 0, 8)),
122        Hatch::DiagonalDense => {
123            pattern(buf, id, 4.0, 4.0, Some(45.0), &|b| line(b, 0, 0, 0, 4))
124        }
125        Hatch::AntiDiagonal => pattern(buf, id, 8.0, 8.0, Some(-45.0), &|b| line(b, 0, 0, 0, 8)),
126        Hatch::Horizontal => pattern(buf, id, 6.0, 6.0, None, &|b| line(b, 0, 3, 6, 3)),
127        Hatch::Vertical => pattern(buf, id, 6.0, 6.0, None, &|b| line(b, 3, 0, 3, 6)),
128        Hatch::Crosshatch => pattern(buf, id, 10.0, 10.0, None, &|b| {
129            line(b, 0, 0, 10, 10);
130            line(b, 10, 0, 0, 10);
131        }),
132        Hatch::Grid => pattern(buf, id, 8.0, 8.0, None, &|b| {
133            line(b, 0, 0, 8, 0);
134            line(b, 0, 0, 0, 8);
135        }),
136        Hatch::DotsSparse => pattern(buf, id, 10.0, 10.0, None, &|b| dot(b, 5.0, 5.0, 1.2)),
137        Hatch::DotsDense => pattern(buf, id, 5.0, 5.0, None, &|b| dot(b, 2.5, 2.5, 1.0)),
138        Hatch::Brick => pattern(buf, id, 16.0, 8.0, None, &|b| {
139            line(b, 0, 8, 16, 8);
140            line(b, 0, 4, 0, 8);
141            line(b, 16, 4, 16, 8);
142            line(b, 0, 0, 16, 0);
143            line(b, 0, 4, 16, 4);
144            line(b, 8, 0, 8, 4);
145        }),
146        Hatch::Zigzag => pattern(buf, id, 12.0, 8.0, None, &|b| {
147            b.push_str(
148                "<path d=\"M0 6 L3 2 L6 6 L9 2 L12 6\" fill=\"none\" stroke=\"black\" stroke-width=\"1\"/>",
149            );
150        }),
151        Hatch::DashedH => pattern(buf, id, 10.0, 6.0, None, &|b| line(b, 0, 3, 5, 3)),
152        Hatch::Checker => pattern(buf, id, 8.0, 8.0, None, &|b| {
153            b.push_str("<rect x=\"0\" y=\"0\" width=\"4\" height=\"4\" fill=\"black\"/>");
154            b.push_str("<rect x=\"4\" y=\"4\" width=\"4\" height=\"4\" fill=\"black\"/>");
155        }),
156        Hatch::SolidWhite | Hatch::SolidBlack => {}
157    }
158}
159
160fn pattern(buf: &mut String, id: &str, w: f64, h: f64, rotate: Option<f64>, body: &dyn Fn(&mut String)) {
161    buf.push_str("<pattern id=\"");
162    buf.push_str(id);
163    buf.push_str("\" patternUnits=\"userSpaceOnUse\" width=\"");
164    svg::num_into(buf, w);
165    buf.push_str("\" height=\"");
166    svg::num_into(buf, h);
167    buf.push('"');
168    if let Some(deg) = rotate {
169        buf.push_str(" patternTransform=\"rotate(");
170        svg::num_into(buf, deg);
171        buf.push_str(")\"");
172    }
173    buf.push('>');
174    body(buf);
175    buf.push_str("</pattern>");
176}
177
178fn line(buf: &mut String, x1: i32, y1: i32, x2: i32, y2: i32) {
179    use std::fmt::Write;
180    let _ = write!(
181        buf,
182        "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"black\" stroke-width=\"1\"/>",
183        x1, y1, x2, y2
184    );
185}
186
187fn dot(buf: &mut String, cx: f64, cy: f64, r: f64) {
188    buf.push_str("<circle cx=\"");
189    svg::num_into(buf, cx);
190    buf.push_str("\" cy=\"");
191    svg::num_into(buf, cy);
192    buf.push_str("\" r=\"");
193    svg::num_into(buf, r);
194    buf.push_str("\" fill=\"black\"/>");
195}
196
197/// Heatmap quantization: map `value` in `[lo, hi]` to a level in
198/// `[0, n_levels)`. Values outside the range clamp to the endpoints.
199pub fn quantize(value: f64, lo: f64, hi: f64, n_levels: usize) -> usize {
200    if lo == hi {
201        return n_levels.saturating_sub(1) / 2;
202    }
203    if value <= lo {
204        return 0;
205    }
206    if value >= hi {
207        return n_levels - 1;
208    }
209    let scaled = ((value - lo) / (hi - lo) * n_levels as f64) as usize;
210    scaled.min(n_levels - 1)
211}
212
213/// `(min, max)` of a 2D grid of numbers. Empty grids return `(0, 1)`.
214pub fn extent(grid: &[Vec<f64>]) -> (f64, f64) {
215    let mut iter = grid.iter().flat_map(|row| row.iter()).copied().filter(|v| v.is_finite());
216    let first = match iter.next() {
217        Some(v) => v,
218        None => return (0.0, 1.0),
219    };
220    let mut lo = first;
221    let mut hi = first;
222    for v in iter {
223        if v < lo {
224            lo = v;
225        }
226        if v > hi {
227            hi = v;
228        }
229    }
230    (lo, hi)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn quantize_clamps() {
239        assert_eq!(quantize(-1.0, 0.0, 1.0, 4), 0);
240        assert_eq!(quantize(2.0, 0.0, 1.0, 4), 3);
241    }
242
243    #[test]
244    fn quantize_midpoint_is_in_middle() {
245        assert_eq!(quantize(0.5, 0.0, 1.0, 4), 2);
246    }
247
248    #[test]
249    fn extent_finds_min_max() {
250        let grid = vec![vec![1.0, 4.0], vec![-1.0, 7.0]];
251        assert_eq!(extent(&grid), (-1.0, 7.0));
252    }
253}